← Back to list

Functional Programming in Java: A Deep Dive into Lambdas and Streams

you won’t just memorize APIs — you’ll understand why they exist.

Abhishektiwari in Stackademic · 2026-07-05 08:56 · 4 claps · 5.7 min read
#java #interview #system-design-interview #coding #programming
Open on Medium ↗
Wiki topics: 💻 · Programming ☁️ · DevOps & Cloud

Functional Programming in Java: A Deep Dive into Lambdas and Streams

you won’t just memorize APIs — you’ll understand why they exist.

Photo by Quilia on Unsplash

Photo by Quilia on Unsplash

If you’ve been preparing for Java backend interviews, you’ve probably noticed one thing: Lambdas and the Stream API appear everywhere.

Whether you’re working with collections, processing data, or writing modern Java applications, understanding these concepts isn’t optional anymore.

But many tutorials jump straight into filter(), map(), and collect() without explaining why Java introduced Lambdas in the first place.

That’s the difference between answering interview questions confidently and simply recalling syntax.

In this article, we’ll build that foundation first and then progressively move through the Stream API until you’re comfortable with both the concepts and the interview questions.

Before Java 8: The Problem

Java has always been an Object-Oriented language.

Passing objects around was easy.

Passing behavior (logic) was not.

Imagine you have a list of employees.

Today you need to filter employees by salary.

Tomorrow by age.

Next week by department.

The iteration logic never changes — only the filtering condition does.

Before Java 8, every new behavior required another anonymous class.

new Filter() {
    @Override
    public boolean test(Employee e) {
        return e.getSalary() > 50000;
    }
}

The actual business logic is just one line:

e.getSalary() > 50000

Everything else is ceremony.

As projects grew, this resulted in:

  • Lots of boilerplate code
  • Poor readability
  • Difficult maintenance
  • No clean way to pass behavior as a parameter

Java needed a better solution.

Enter Functional Programming

Functional Programming (FP) is a programming style where behavior can be treated like data.

Instead of writing multiple methods:

filterBySalary()
filterByAge()
filterByDepartment()

You write one reusable method:

filter(list, condition)

The algorithm stays the same.

Only the condition changes.

Unlike languages such as Haskell or Scala, Java did not become a purely functional language.

Instead, Java 8 added functional programming features while keeping its object-oriented foundation.

Interview Tip: Java supports functional programming but is not a pure functional programming language.

Functional Interfaces: The Foundation of Lambdas

A Lambda can only work with a Functional Interface.

A Functional Interface contains exactly one abstract method (SAM — Single Abstract Method).

Example:

@FunctionalInterface
interface Calculator {
    int add(int a, int b);
}

This is valid because there’s only one abstract method.

This isn’t:

interface Service {
    void save();
    void delete();
}

Since there are two abstract methods, Java wouldn’t know which one the Lambda should implement.

Why @FunctionalInterface?

The annotation is optional but highly recommended because it provides compile-time validation.

It prevents someone from accidentally adding another abstract method later.

A Functional Interface can still contain:

  • Default methods
  • Static methods
  • Methods inherited from Object

Only abstract methods count toward the SAM rule.

Common Functional Interfaces Every Java Developer Should Know

You’ll encounter these repeatedly while working with Streams.

Lambda Expressions

A Lambda Expression is simply a concise implementation of a Functional Interface.

General syntax:

(parameters) -> expression

Instead of writing:

Calculator calculator = new Calculator() {
    @Override
    public int add(int a, int b) {
        return a + b;
    }
};

You can write:

Calculator calculator = (a, b) -> a + b;

The unnecessary syntax disappears.

Only the business logic remains.

Type Inference Makes Lambdas Cleaner

Java already knows the parameter types from the Functional Interface.

Instead of:

(int a, int b) -> a + b

Write:

(a, b) -> a + b

Cleaner.

More readable.

Preferred in production code.

Lambda Syntax Variations

No Parameters

() -> System.out.println("Hello")

One Parameter

name -> System.out.println(name)

Multiple Parameters

(a, b) -> a + b

Multiple Statements

(a, b) -> {
    int sum = a + b;
    return sum;
}

Variable Capture and Effectively Final

Lambdas can access variables from the surrounding scope.

int bonus = 1000;
employees.stream()
         .filter(e -> e.getSalary() > bonus);

However, the captured variable must be effectively final.

This works:

int bonus = 1000;

This doesn’t:

bonus++;

Java captures the value of the local variable, not the variable itself. Allowing modifications afterward could lead to confusing and unsafe behavior.

Method References

Sometimes a Lambda simply calls an existing method.

Instead of:

name -> System.out.println(name)

You can write:

System.out::println

This is called a Method Reference.

There are four common types:

Static Method

Integer::parseInt
Math::abs

Instance Method of a Particular Object

printer::print

Instance Method of an Arbitrary Object

String::length
Employee::getName

Constructor Reference

Employee::new

Method references improve readability but offer essentially the same runtime performance as Lambdas.

Why the Stream API?

Once Java could pass behavior using Lambdas, it became possible to build a powerful data-processing API.

That’s how the Stream API was introduced.

Before Java 8:

List<String> result = new ArrayList<>();
for (Employee e : employees) {
    if (e.getSalary() > 50000) {
        result.add(e.getName());
    }
}

With Streams:

List<String> result = employees.stream()
        .filter(e -> e.getSalary() > 50000)
        .map(Employee::getName)
        .toList();

The intent becomes much clearer.

What Exactly Is a Stream?

A Stream is not a data structure.

It doesn’t store data.

Instead, it processes data from a source such as:

  • Collections
  • Arrays
  • Files
  • Generated values

Think of a Collection as a warehouse and a Stream as a conveyor belt that processes items one by one.

Stream Characteristics

Streams:

  • Do not store data
  • Process data from a source
  • Use lazy evaluation
  • Support functional programming
  • Use internal iteration
  • Can be consumed only once
  • Support parallel processing

One important point:

Streams never modify the original collection.

List<Integer> even = numbers.stream()
                            .filter(n -> n % 2 == 0)
                            .toList();

The original numbers list remains unchanged.

Stream Lifecycle

Every Stream follows the same lifecycle:

Source
   ↓
Intermediate Operations
   ↓
Terminal Operation

Example:

employees.stream()
         .filter(...)
         .map(...)
         .toList();

Lazy Evaluation

Intermediate operations don’t execute immediately.

employees.stream()
         .filter(...)
         .map(...);

Nothing happens.

Execution begins only when a terminal operation appears.

.toList()
.count()
.collect(...)

This allows Java to optimize the entire pipeline and avoid unnecessary work.

Intermediate Operations

Intermediate operations always return another Stream.

Some of the most commonly used ones are:

filter()

Keeps matching elements.

.filter(e -> e.getSalary() > 50000)

Uses Predicate<T>.

map()

Transforms one object into another.

.map(Employee::getName)

Uses Function<T,R>.

flatMap()

Flattens nested collections.

.flatMap(List::stream) Converts Stream<List<T>> into:

Stream<T>

distinct()

Removes duplicate elements.

For custom objects, it relies on correctly implemented equals() and hashCode().

sorted()

Natural ordering: .sorted()

Custom ordering:

.sorted(Comparator.comparing(Employee::getSalary))

peek()

Useful for debugging and logging.

.peek(System.out::println)

Avoid using it for business logic.

limit() and skip()

Useful for pagination.

.limit(10)
.skip(20)

Terminal Operations

Terminal operations execute the pipeline and close the Stream.

Common examples include:

  • toList()
  • collect()
  • reduce()
  • count()
  • forEach()
  • findFirst()
  • findAny()
  • max()
  • min()
  • anyMatch()
  • allMatch()
  • noneMatch()

Once a terminal operation completes, the Stream cannot be reused.

Collectors

Collectors provide flexible ways to gather Stream results.

Some of the most important ones are:

toList()

Collectors.toList()

toSet()

Collectors.toSet()

toMap()

Collectors.toMap(
    Employee::getId,
    Employee::getName
)

If duplicate keys exist, supply a merge function.

groupingBy()

One of the most frequently asked interview topics.

Collectors.groupingBy(Employee::getDepartment)

Produces:

Department
      ↓
List<Employee>

partitioningBy()

Splits elements into exactly two groups.

Collectors.partitioningBy(
    e -> e.getSalary() > 50000
)

Returns:

Map<Boolean, List<Employee>>

mapping()

Transforms grouped values.

Collectors.groupingBy(
    Employee::getDepartment,
    Collectors.mapping(
        Employee::getName,
        Collectors.toList()
    )
)

summarizingInt()

Produces statistics in one pass.

Collectors.summarizingInt(Employee::getSalary)

Returns:

  • Count
  • Sum
  • Average
  • Minimum
  • Maximum

Common Interview Comparisons

Best Practices

  • Streams never modify the source collection.
  • A Stream can be consumed only once.
  • Intermediate operations are lazy.
  • Terminal operations trigger execution.
  • Prefer method references when they improve readability.
  • Use peek() only for debugging.
  • Remember that Collectors.toMap() throws an exception for duplicate keys unless a merge function is provided.
  • Ensure custom objects implement equals() and hashCode() correctly when using distinct().
  • Prefer findAny() for better scalability with parallel streams.

Final Thoughts

Lambdas and the Stream API fundamentally changed how modern Java code is written.

Lambdas solved Java’s inability to pass behavior cleanly, while Streams provided a declarative and composable way to process data.

If you understand the progression:

Problem → Functional Programming → Functional Interfaces → Lambdas → Method References → Streams → Collectors

Happy coding! 🚀

Before you go

Thousands of developers share what they’re building, learning, and discovering across our publications every month. One account connects you to our entire network of publications and communities.

***Explore more at plainenglish.io*.


메타데이터
post_id
ab955004d427
slug
functional-programming-in-java-a-deep-dive-into-lambdas-and-streams-ab955004d427
url
https://blog.stackademic.com/functional-programming-in-java-a-deep-dive-into-lambdas-and-streams-ab955004d427
canonical_url
https://blog.stackademic.com/functional-programming-in-java-a-deep-dive-into-lambdas-and-streams-ab955004d427
author_url
https://medium.com/@abhi9720
status
ok
fetched_at
2026-07-08 20:12:56