Working with java.util.function.Consumer in Java

Learning Objectives

  • Understand that Consumer is a functional interface that accepts input and performs an operation without returning a value
  • Learn to use Consumer with lambda expressions for side-effect operations
  • Apply the accept() method to process data
  • Use Consumers with Stream API operations like forEach()
  • Chain multiple Consumers together using andThen()

Introduction

Most functional interfaces in Java focus on transforming data or producing results. Predicate tests conditions and returns booleans. Function takes input and produces output. But what about operations that just do something without returning anything? That's where Consumer enters the picture.

Consumer represents operations that consume input and perform side effects—printing to console, updating a database, modifying a field, sending a network request. These are the everyday actions that make programs useful beyond pure computation. While functional programming purists might view side effects with suspicion, pragmatic Java development recognizes that applications must interact with the outside world. Consumer formalizes this pattern with type safety and composability.

The genius of Consumer lies in its simplicity. One method, accept(), takes an argument and returns nothing (void). This straightforward contract makes Consumer the natural choice for operations like logging, displaying data, or triggering actions. When combined with the Stream API, Consumers enable clean, declarative processing of collections where each element receives some treatment without transforming into something else.

The Consumer Interface Structure

Consumer is a functional interface with a single abstract method that accepts one argument and returns void:

@FunctionalInterface
public interface Consumer<T> {
    void accept(T t);
    // default method: andThen()
}

The generic type parameter T represents the type of input the consumer processes. A Consumer<String> operates on strings, a Consumer<Integer> on integers, and a Consumer<Employee> on employee objects. The void return type distinguishes Consumer from other functional interfaces—it performs an action rather than computing a result.

Because Consumer has exactly one abstract method, you can implement it with lambda expressions or method references. The functional interface annotation ensures the compiler enforces this contract, catching errors if someone accidentally adds another abstract method.

Creating Consumers with Lambda Expressions

Lambda expressions provide concise syntax for creating consumers. The pattern follows (parameter) -> { statements }, where the statements perform the desired side effect:

package blog.academy.javapro;

import java.util.function.Consumer;

public class ConsumerBasics {
    public static void main(String[] args) {
        Consumer<String> printUpperCase = s -> System.out.println(s.toUpperCase());
        Consumer<Integer> printSquare = n -> System.out.println(n * n);
        Consumer<String> logMessage = msg -> System.out.println("[LOG] " + msg);

        printUpperCase.accept("hello world");
        printSquare.accept(5);
        printSquare.accept(12);
        logMessage.accept("Application started");
        logMessage.accept("Processing complete");
    }
}

Each consumer encapsulates a specific action. The printUpperCase consumer converts strings to uppercase and displays them. The printSquare consumer calculates and prints the square of a number. The logMessage consumer formats messages with a logging prefix. Notice how each consumer focuses on doing one thing—this single-responsibility approach makes consumers easy to understand and reuse.

When the action requires multiple statements, you can use a block with curly braces:

package blog.academy.javapro;

import java.util.function.Consumer;

public class ConsumerWithBlocks {
    static class Account {
        private String accountNumber;
        private double balance;

        public Account(String accountNumber, double balance) {
            this.accountNumber = accountNumber;
            this.balance = balance;
        }

        public String getAccountNumber() {
            return accountNumber;
        }

        public double getBalance() {
            return balance;
        }

        public void setBalance(double balance) {
            this.balance = balance;
        }
    }

    public static void main(String[] args) {
        Consumer<Account> processAccount = account -> {
            System.out.println("Processing account: " + account.getAccountNumber());
            double fee = account.getBalance() * 0.01;
            account.setBalance(account.getBalance() - fee);
            System.out.println("Applied fee: $" + fee);
            System.out.println("New balance: $" + account.getBalance());
        };

        Account acc = new Account("ACC-12345", 1000.0);
        processAccount.accept(acc);
    }
}

This consumer performs multiple related actions: logging account processing, calculating a fee, updating the balance, and displaying results. The block structure groups these operations together, making the consumer's purpose clear.

Method references work when an existing method matches the consumer signature—taking one parameter and returning void:

package blog.academy.javapro;

import java.util.function.Consumer;
import java.util.List;
import java.util.Arrays;

public class ConsumerMethodReference {
    private static void logError(String error) {
        System.err.println("[ERROR] " + error);
    }

    private static void displayNumber(Integer num) {
        System.out.println("Number: " + num);
    }

    public static void main(String[] args) {
        Consumer<String> errorLogger = ConsumerMethodReference::logError;
        Consumer<Integer> numberDisplay = ConsumerMethodReference::displayNumber;
        Consumer<String> systemPrint = System.out::println;

        errorLogger.accept("Failed to connect to database");
        numberDisplay.accept(42);
        systemPrint.accept("Using method reference for printing");
    }
}

The System.out::println method reference creates a consumer particularly common in stream operations. It's so frequently used that recognizing this pattern becomes second nature when processing collections.

Understanding the accept() Method

The accept() method is where consumers actually do their work. When you call accept() with an argument, the consumer executes its defined action on that input. The method returns void, so you invoke it purely for its side effects:

package blog.academy.javapro;

import java.util.function.Consumer;
import java.util.ArrayList;
import java.util.List;

public class AcceptMethodDemo {
    static class ShoppingCart {
        private List<String> items = new ArrayList<>();
        private double total = 0.0;

        public void addItem(String item, double price) {
            items.add(item);
            total += price;
        }

        public List<String> getItems() {
            return items;
        }

        public double getTotal() {
            return total;
        }
    }

    public static void main(String[] args) {
        ShoppingCart cart = new ShoppingCart();

        Consumer<ShoppingCart> addDefaults = c -> {
            c.addItem("Shipping", 5.99);
            c.addItem("Tax", c.getTotal() * 0.08);
        };

        Consumer<ShoppingCart> displaySummary = c -> {
            System.out.println("Cart Items:");
            c.getItems().forEach(item -> System.out.println("  - " + item));
            System.out.println("Total: $" + String.format("%.2f", c.getTotal()));
        };

        cart.addItem("Widget", 29.99);
        cart.addItem("Gadget", 49.99);

        addDefaults.accept(cart);
        displaySummary.accept(cart);
    }
}

The addDefaults consumer modifies the cart by adding shipping and tax. The displaySummary consumer prints cart contents without changing anything. Both use accept() to perform their respective operations. This pattern separates data modification from data display, making each consumer's responsibility explicit.

Chaining Consumers with andThen()

The real power of Consumer emerges when you compose multiple consumers using andThen(). This default method creates a new consumer that performs one action followed by another, executing them in sequence:

package blog.academy.javapro;

import java.util.function.Consumer;

public class ConsumerChaining {
    public static void main(String[] args) {
        Consumer<String> printOriginal = s -> System.out.println("Original: " + s);
        Consumer<String> printUpperCase = s -> System.out.println("Uppercase: " + s.toUpperCase());
        Consumer<String> printLength = s -> System.out.println("Length: " + s.length());

        Consumer<String> fullProcessing = printOriginal
                .andThen(printUpperCase)
                .andThen(printLength);

        fullProcessing.accept("hello world");

        System.out.println("\nProcessing another string:");
        fullProcessing.accept("Java");
    }
}

The fullProcessing consumer executes three operations in order: printing the original string, printing it in uppercase, and printing its length. Each andThen() call creates a new consumer that includes the previous operations plus the new one. The chain reads naturally from left to right, showing the sequence of actions.

Chaining proves particularly useful when you want to apply multiple transformations or logging steps to data:

package blog.academy.javapro;

import java.util.function.Consumer;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

public class ConsumerPipeline {
    static class Order {
        private String orderId;
        private String status;
        private LocalDateTime timestamp;

        public Order(String orderId) {
            this.orderId = orderId;
            this.status = "PENDING";
            this.timestamp = LocalDateTime.now();
        }

        public String getOrderId() {
            return orderId;
        }

        public String getStatus() {
            return status;
        }

        public void setStatus(String status) {
            this.status = status;
            this.timestamp = LocalDateTime.now();
        }

        public LocalDateTime getTimestamp() {
            return timestamp;
        }
    }

    public static void main(String[] args) {
        Consumer<Order> validate = order -> {
            System.out.println("Validating order: " + order.getOrderId());
            order.setStatus("VALIDATED");
        };

        Consumer<Order> process = order -> {
            System.out.println("Processing order: " + order.getOrderId());
            order.setStatus("PROCESSING");
        };

        Consumer<Order> complete = order -> {
            System.out.println("Completing order: " + order.getOrderId());
            order.setStatus("COMPLETED");
        };

        Consumer<Order> logTimestamp = order -> {
            DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
            System.out.println("Last updated: " + order.getTimestamp().format(formatter));
        };

        Consumer<Order> orderPipeline = validate
                .andThen(process)
                .andThen(complete)
                .andThen(logTimestamp);

        Order order = new Order("ORD-12345");
        orderPipeline.accept(order);

        System.out.println("\nFinal status: " + order.getStatus());
    }
}

This pipeline processes an order through multiple stages, updating its status at each step and logging the final timestamp. Each consumer represents one stage in the workflow, and andThen() chains them into a complete processing sequence. This pattern separates concerns while maintaining clear execution order.

You can also build conditional chains where you decide at runtime which consumers to include:

package blog.academy.javapro;

import java.util.function.Consumer;

public class ConditionalChaining {
    static class User {
        private String username;
        private boolean isPremium;
        private boolean isAdmin;

        public User(String username, boolean isPremium, boolean isAdmin) {
            this.username = username;
            this.isPremium = isPremium;
            this.isAdmin = isAdmin;
        }

        public String getUsername() {
            return username;
        }

        public boolean isPremium() {
            return isPremium;
        }

        public boolean isAdmin() {
            return isAdmin;
        }
    }

    public static void main(String[] args) {
        Consumer<User> greet = u -> System.out.println("Welcome, " + u.getUsername());
        Consumer<User> offerPremium = u -> System.out.println("Enjoy premium features!");
        Consumer<User> showAdminPanel = u -> System.out.println("Admin panel available");

        User regularUser = new User("alice", false, false);
        User premiumUser = new User("bob", true, false);
        User adminUser = new User("charlie", true, true);

        Consumer<User> processUser = greet;

        System.out.println("Regular user:");
        processUser.accept(regularUser);

        System.out.println("\nPremium user:");
        Consumer<User> premiumProcess = greet.andThen(offerPremium);
        premiumProcess.accept(premiumUser);

        System.out.println("\nAdmin user:");
        Consumer<User> adminProcess = greet.andThen(offerPremium).andThen(showAdminPanel);
        adminProcess.accept(adminUser);
    }
}

Here the consumer chain grows based on user attributes. Regular users get a greeting. Premium users get the greeting plus premium features. Admins get all three actions. This approach builds processing pipelines dynamically rather than hardcoding every possible combination.

Using Consumers with Stream API Operations

Consumers integrate seamlessly with Java's Stream API, most commonly through the forEach() terminal operation. This method accepts a consumer and applies it to each element in the stream:

package blog.academy.javapro;

import java.util.Arrays;
import java.util.List;
import java.util.function.Consumer;

public class ConsumerWithStreams {
    public static void main(String[] args) {
        List<String> languages = Arrays.asList("Java", "Python", "JavaScript", "C++", "Go");

        Consumer<String> printLanguage = lang -> System.out.println("Language: " + lang);

        languages.stream()
                .forEach(printLanguage);

        System.out.println("\nWith inline consumer:");
        languages.stream()
                .forEach(lang -> System.out.println(lang.toUpperCase()));
    }
}

The forEach() operation applies the consumer to each element sequentially. You can pass a named consumer or define the action inline with a lambda. The pattern becomes particularly powerful when processing collections of complex objects:

package blog.academy.javapro;

import java.util.Arrays;
import java.util.List;
import java.util.function.Consumer;

public class StreamConsumerProcessing {
    static class Transaction {
        private String id;
        private String type;
        private double amount;
        private boolean processed;

        public Transaction(String id, String type, double amount) {
            this.id = id;
            this.type = type;
            this.amount = amount;
            this.processed = false;
        }

        public String getId() {
            return id;
        }

        public String getType() {
            return type;
        }

        public double getAmount() {
            return amount;
        }

        public boolean isProcessed() {
            return processed;
        }

        public void setProcessed(boolean processed) {
            this.processed = processed;
        }

        @Override
        public String toString() {
            return String.format("%s: %s $%.2f [%s]",
                    id, type, amount, processed ? "PROCESSED" : "PENDING");
        }
    }

    public static void main(String[] args) {
        List<Transaction> transactions = Arrays.asList(
                new Transaction("TXN-001", "DEBIT", 150.00),
                new Transaction("TXN-002", "CREDIT", 500.00),
                new Transaction("TXN-003", "DEBIT", 75.50),
                new Transaction("TXN-004", "CREDIT", 1200.00)
        );

        Consumer<Transaction> processTransaction = txn -> {
            System.out.println("Processing: " + txn.getId());
            txn.setProcessed(true);
        };

        Consumer<Transaction> logTransaction = txn -> {
            System.out.println("  Type: " + txn.getType() + ", Amount: $" + txn.getAmount());
        };

        Consumer<Transaction> fullProcessing = processTransaction.andThen(logTransaction);

        System.out.println("Processing all transactions:");
        transactions.stream()
                .forEach(fullProcessing);

        System.out.println("\nFinal state:");
        transactions.forEach(System.out::println);
    }
}

This example combines consumer chaining with stream operations. The fullProcessing consumer marks each transaction as processed and logs its details. Applying this chained consumer through forEach() processes the entire collection uniformly. The pattern scales well—adding another consumer to the chain extends functionality without modifying existing code.

Consumers also work with filtered streams, operating only on elements that pass certain criteria:

package blog.academy.javapro;

import java.util.Arrays;
import java.util.List;
import java.util.function.Consumer;
import java.util.function.Predicate;

public class FilteredConsumerApplication {
    static class Employee {
        private String name;
        private String department;
        private double salary;

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

        public String getName() {
            return name;
        }

        public String getDepartment() {
            return department;
        }

        public double getSalary() {
            return salary;
        }

        public void setSalary(double salary) {
            this.salary = salary;
        }
    }

    public static void main(String[] args) {
        List<Employee> employees = Arrays.asList(
                new Employee("Alice", "Engineering", 75000),
                new Employee("Bob", "Engineering", 85000),
                new Employee("Charlie", "Sales", 65000),
                new Employee("Diana", "Engineering", 95000),
                new Employee("Eve", "Sales", 70000)
        );

        Predicate<Employee> isEngineering = e -> e.getDepartment().equals("Engineering");
        Predicate<Employee> isHighEarner = e -> e.getSalary() > 80000;

        Consumer<Employee> giveBonus = e -> {
            double bonus = e.getSalary() * 0.10;
            e.setSalary(e.getSalary() + bonus);
            System.out.println(e.getName() + " received bonus: $" + String.format("%.2f", bonus));
        };

        System.out.println("Giving bonuses to high-earning engineers:");
        employees.stream()
                .filter(isEngineering.and(isHighEarner))
                .forEach(giveBonus);

        System.out.println("\nAll employees after bonuses:");
        employees.forEach(e ->
                System.out.println(e.getName() + " - $" + String.format("%.2f", e.getSalary())));
    }
}

The stream filters employees to find high-earning engineers, then applies the bonus consumer only to those matching employees. This combination of predicates and consumers enables declarative data processing—you specify what to select and what to do with selections, leaving iteration details to the Stream API.

Summary

The Consumer functional interface provides a clean abstraction for operations that process input without producing output. Its single method accept() executes side effects—printing, logging, modifying state, triggering external actions—making it the natural choice for any operation where you care about what happens rather than what returns. The void return type explicitly signals this intent to future maintainers.

Lambda expressions make creating consumers syntactically lightweight and readable. A simple x -> System.out.println(x) expresses the action directly without boilerplate, while multi-statement blocks handle more complex operations. Method references offer even more concise syntax when existing methods match the consumer contract, with System.out::println appearing so frequently in stream operations that it becomes idiomatic.

The andThen() method enables consumer composition, chaining multiple actions into sequential pipelines. Each consumer in the chain performs its task, then passes control to the next. This compositional approach mirrors Unix pipes or middleware chains, where simple components combine to create sophisticated behavior. You can build these chains statically for fixed workflows or dynamically based on runtime conditions, adapting processing to different scenarios without duplicating logic.

Integration with the Stream API represents where consumers truly shine in modern Java. The forEach() terminal operation applies a consumer to every stream element, enabling declarative collection processing. Combined with filtering and mapping operations, consumers handle the final "do something with each result" step that most data processing requires. This pattern eliminates explicit loops and index management, producing code that reads more like a specification of what should happen than instructions for how to make it happen. Consumers exemplify how functional programming concepts enhance Java's object-oriented foundation, providing tools that make side-effect operations as composable and maintainable as pure functions.

Complete Core Java Programming Course

Master Java from scratch to advanced! This comprehensive bootcamp covers everything from your first line of code to building complex applications, with expert guidance on collections, multithreading, exception handling, and more.

Leave a Comment