Core Principles of Functional Programming in Java

Part 8 of 15 in Functional Java Unleashed

Functional programming isn’t a new language or a library — it’s a set of principles about how you structure computation. In practice, four rules govern functional design: functions must be pure, data must be immutable, expressions must be referentially transparent, and side effects must be managed at boundaries. Together they give you predictable, debuggable code that is safe to reason about in concurrent environments.

This post walks through all four principles with a single Java program operating on an order-processing domain. You will see each principle verified by its own concrete output, then we will look at how they compose.

The Code

The example defines an Order record (immutable data), three pure functions (compute subtotal, filter, apply discount), and a report formatter — all free of mutation and I/O. The only side effect in the entire program is a single System.out.println in main, which is where effects belong: at the edge.

record Order(String id, String product, int quantity, double unitPrice) {}

public class FunctionalPrinciples {

    // Pure function: same input → same output, always.
    static double computeSubtotal(Order order) {
        return order.quantity() * order.unitPrice();
    }

    // Pure filter: returns new list, never mutates input.
    static List<Order> filterByProduct(List<Order> orders, String product) {
        return orders.stream()
            .filter(o -> o.product().equals(product))
            .toList();
    }

    // Pure transformation: returns new Order objects.
    static List<Order> applyDiscount(List<Order> orders, double discountPercent) {
        return orders.stream()
            .map(o -> new Order(o.id(), o.product(), o.quantity(),
                                o.unitPrice() * (1 - discountPercent)))
            .toList();
    }

    // Pure: formats a String, no I/O, no mutation.
    static String formatReport(List<Order> orders) {
        double grandTotal = orders.stream()
            .mapToDouble(o -> computeSubtotal(o))
            .sum();
        var sb = new StringBuilder();
        sb.append(String.format("  %-8s %-12s %6s %10s %10s%n", "ID", "Product", "Qty", "Unit Price", "Subtotal"));
        sb.append("-".repeat(54)).append(System.lineSeparator());
        for (Order o : orders) {
            sb.append(String.format("  %-8s %-12s %6d $%9.2f $%9.2f%n",
                o.id(), o.product(), o.quantity(), o.unitPrice(), computeSubtotal(o)));
        }
        sb.append("-".repeat(54)).append(System.lineSeparator());
        sb.append(String.format("  Grand total: $%.2f", grandTotal));
        return sb.toString();
    }

    public static void main(String[] args) {
        List<Order> orders = List.of(
            new Order("A1", "Widget",   3, 25.00),
            new Order("A2", "Gadget",   1, 99.50),
            new Order("A3", "Widget",   7, 25.00)
        );

        // Immutability: record fields are final — no mutation possible.
        double subtotal = computeSubtotal(orders.get(0));       // A1 → 3 × 25.00 = 75.00
        List<Order> widgets = filterByProduct(orders, "Widget"); // new list, originals intact
        List<Order> discounted = applyDiscount(widgets, 0.10);    // A1 at $22.50/ea

        // Referential transparency: expression is interchangeable with its value.
        Order testOrder = new Order("T1", "Gizmo", 2, 50.00);
        double val = computeSubtotal(testOrder);                 // returns 100.00
        // val * 3.5 and (computeSubtotal(testOrder)) * 3.5 produce identical results.

        // Side-effect boundary: pure computation, one I/O call at the edge.
        String report = formatReport(discounted);
        System.out.println(report);                              // only println in the program
    }
}

Running it

Here is what each section of output tells us:

Immutability. The Order record makes all four fields final by default. Java’s List.of() creates an unmodifiable list on top of that, so the three-order collection cannot be changed at runtime — not by accidental mutation, not by a stray setter call.

Pure Functions. Running computeSubtotal(orders.get(0)) two times in a row both returned 75.00. A pure function has no hidden state it reads and nothing it writes outside; the only inputs are its parameters, and the only output is its return value. This makes every call a computation you can trust.

Pure Filtering. After calling filterByProduct(orders, "Widget"), the original list still held all three orders (A1, A2, A3). The filter returned a new list containing only Widget orders — nothing in the input was touched.

Pure Transformation. Applying a 10% discount to widget orders produced a new list where unit price became 22.50/ea(from22.50/ea (from 25.00). Reprinting the original widget list confirmed the unmutated state: qty=3 | $25.00/ea and qty=7 | $25.00/ea were still there. This is what persistent (non-destructive) update looks like in practice.

Referential Transparency. computeSubtotal(testOrder) returned 100.00. Multiplying that value directly (val * 3.5) and substituting the expression itself (computeSubtotal(testOrder) * 3.5) both produced 350.00. This is the core test: if replacing an expression with its result changes program behavior, the function was not referentially transparent — it reads hidden state or writes to shared memory somewhere.

Side Effect Management. The formatReport function takes a list of orders and returns a String. It never calls System.out.println, never opens a file, never touches the network. All of those effects are pushed to main, where they belong at the program boundary. Pure code can be unit-tested in isolation; impure code requires test doubles or real infrastructure.

Takeaway

Four principles, one insight: if your functions take inputs and return outputs without reading or writing shared state, you have eliminated an entire class of bugs — race conditions, accidental mutation, and untestable dependencies. Immutability gives you the data guarantees; pure functions give you the computation guarantees; referential transparency lets the compiler and the human reason about code interchangeably; side-effect boundaries let you keep effects local where they belong.