Back to Stream Practice: Employee Analysis
Q7Question 7 of 43

Merge two employee lists: unique by ID, keep-existing and deep-merge strategies

Key Concepts
  • Stream.concat(list1, list2).collect(toMap(getId, identity, mergeFunction)) merges two lists with duplicate handling
  • The merge function (existing, incoming) -> existing keeps the first occurrence (keep-existing strategy)
  • The merge function (existing, incoming) -> incoming keeps the last occurrence (keep-incoming strategy)
  • A custom merge function can select specific fields from each source (deep-merge strategy)
  • Without a merge function, toMap throws IllegalStateException on duplicate keys
  • Convert the result Map back to a List with new ArrayList<>(merged.values())

Definition

Use Stream.concat to combine two lists, then Collectors.toMap with a merge function to handle duplicate employee IDs: keep-existing, keep-incoming, or deep-merge specific fields.
DIAGRAM
- **Stream.concat(list1.stream(), list2.stream())** joins two streams into one sequential stream.
- **Collectors.toMap(keyMapper, valueMapper, mergeFunction)** collects into a Map where the merge function handles duplicate keys.
- **Keep-existing**: `(existing, incoming) -> existing` retains the first occurrence when IDs clash.
- **Keep-incoming**: `(existing, incoming) -> incoming` retains the last occurrence (latest update).
- **Deep-merge**: `(existing, incoming) -> new Employee(existing.id(), incoming.name(), existing.department(), incoming.salary())` selects specific fields from each source.
- Without the merge function, duplicate keys throw `IllegalStateException`.

National Bank of India has two branches that each maintain their own employee lists. Some employees transferred between branches and appear in both lists. The head office merges the lists. "Keep-existing" means the head office record wins. "Keep-incoming" means the branch's updated record wins. "Deep-merge" means take the latest salary from the branch but keep the department assignment from head office.

Understanding the Concepts

What is the problem?

Two lists of employees may contain overlapping entries (same employee ID). You need to merge them into a single list where each employee ID appears exactly once, with a strategy for resolving duplicates.

How does the keep-existing strategy work?

The first list's entry wins when duplicate IDs are found.

JAVA
package com.bank.service;

import java.math.BigDecimal;
import java.util.*;
import java.util.stream.*;

public class EmployeeMergeDemo {
    record Employee(String id, String name, String department,
                    BigDecimal salary) {}

    public static void main(String[] args) {
        List<Employee> hqList = List.of(
            new Employee("E001", "Rajesh",  "TRADING",    new BigDecimal("1800000")),
            new Employee("E002", "Priya",   "RISK",       new BigDecimal("1000000")),
            new Employee("E003", "Amit",    "OPERATIONS", new BigDecimal("1500000"))
        );

        List<Employee> branchList = List.of(
            new Employee("E002", "Priya",   "RISK",       new BigDecimal("1100000")),
            new Employee("E003", "Amit",    "TRADING",    new BigDecimal("1600000")),
            new Employee("E004", "Kavita",  "RISK",       new BigDecimal("1200000"))
        );

        // KEEP-EXISTING: HQ record wins on duplicate IDs
        Map<String, Employee> merged = Stream.concat(
                hqList.stream(), branchList.stream())
            .collect(Collectors.toMap(
                Employee::id,
                e -> e,
                (existing, incoming) -> existing));  // keep first

        merged.values().forEach(e ->
            System.out.println(e.id() + ": " + e.name()
                + " " + e.department() + " Rs." + e.salary()));
    }
}
TEXT
E001: Rajesh TRADING Rs.1800000
E002: Priya RISK Rs.1000000
E003: Amit OPERATIONS Rs.1500000
E004: Kavita RISK Rs.1200000

E002 and E003 use the HQ values because the keep-existing strategy retains the first occurrence.

How does the keep-incoming strategy work?

JAVA
// KEEP-INCOMING: branch's updated record wins
Map<String, Employee> keepIncoming = Stream.concat(
        hqList.stream(), branchList.stream())
    .collect(Collectors.toMap(
        Employee::id,
        e -> e,
        (existing, incoming) -> incoming));  // keep last

// E002: Priya RISK Rs.1100000  (branch salary)
// E003: Amit TRADING Rs.1600000 (branch dept + salary)

How does deep-merge work?

Select specific fields from each source to build the merged employee.

JAVA
// DEEP-MERGE: keep HQ department, take branch salary
Map<String, Employee> deepMerged = Stream.concat(
        hqList.stream(), branchList.stream())
    .collect(Collectors.toMap(
        Employee::id,
        e -> e,
        (hq, branch) -> new Employee(
            hq.id(),
            branch.name(),           // take updated name
            hq.department(),         // keep HQ department
            branch.salary())));      // take updated salary

// E003: Amit OPERATIONS Rs.1600000 (HQ dept + branch salary)

What happens without a merge function?

JAVA
// WRONG: no merge function -- throws on duplicate keys
Map<String, Employee> broken = Stream.concat(
        hqList.stream(), branchList.stream())
    .collect(Collectors.toMap(
        Employee::id,
        e -> e));  // IllegalStateException: Duplicate key E002

How do you convert the result back to a sorted list?

JAVA
// Convert Map values to sorted List
List<Employee> mergedList = merged.values().stream()
    .sorted(Comparator.comparing(Employee::id))
    .toList();

How is toMap different from groupingBy for merging?

| Collector | Duplicate Handling | Result Type |
|-----------|-------------------|-------------|
| toMap with merge | Merge function decides which value wins | Map |
| groupingBy | Keeps ALL values in a list | Map> |

Use toMap when you want exactly one value per key. Use groupingBy when you want all values per key.

What are common mistakes?

WRONG: Forgetting the merge function and getting IllegalStateException.

WRONG: Assuming Stream.concat removes duplicates. It does not. It simply concatenates the two streams.

What should a senior engineer know?

  • The merge function must be associative for parallel streams. (a, b) -> a and (a, b) -> b are both associative.
  • For large lists, consider using toMap with a LinkedHashMap::new supplier (four-argument toMap) to preserve insertion order.
  • In a real banking merge, the deep-merge strategy often needs validation: for example, ensure the branch salary is not lower than the HQ salary before accepting it.
  • Stream.concat creates a lazily concatenated stream, so it does not copy all elements upfront.

Visual Overview

DIAGRAM
  HQ List                    Branch List
  ┌────────────────┐        ┌────────────────┐
  │ E001 Rajesh    │        │ E002 Priya *   │
  │ E002 Priya     │        │ E003 Amit  *   │
  │ E003 Amit      │        │ E004 Kavita    │
  └───────┬────────┘        └───────┬────────┘
          │                         │
          └─────────┬───────────────┘
                    ▼
            Stream.concat()
          [E001, E002, E003, E002*, E003*, E004]
                    │
                    ▼
            toMap(id, merge)
          ┌──────────────────────┐
          │ E001 → Rajesh (HQ)   │
          │ E002 → Priya (merged)│
          │ E003 → Amit (merged) │
          │ E004 → Kavita (new)  │
          └──────────────────────┘

Key Takeaway

Merging two employee lists with unique IDs uses Stream.concat to combine both streams, then Collectors.toMap with a merge function to handle duplicate keys. Without the merge function, duplicate keys throw IllegalStateException. The merge function (existing, incoming) -> existing keeps the first occurrence. (existing, incoming) -> incoming keeps the last. A custom merge function selects specific fields from each source for a deep-merge.

At National Bank of India, the head office merges its list with the branch list. E002 and E003 appear in both. The keep-existing strategy preserves HQ salary for E002 (Rs.10L). The keep-incoming strategy takes the branch's updated salary (Rs.11L). The deep-merge strategy takes the name and salary from the branch but keeps the department from HQ.

Remember one sentence: toMap with a merge function is the stream way to deduplicate by key, and the merge function decides which value survives when keys collide.

---

Share
Use ← → arrow keys to navigate