Reactor Expand vs ExpandDeep: Directory Traversal Strategies
When exploring directory structures in a reactive programming environment, the strategies of breadth-first and depth-first traversal play a crucial role. This guide explores how to use Reactor’s expand and expandDeep operators to traverse and emit paths in a reactive stream.
1. Overview
Project Reactor provides two powerful operators for recursive exploration: expand (breadth-first) and expandDeep (depth-first). These operators are particularly useful when working with hierarchical structures like file systems, where you need to traverse directories and process files reactively.
The ReactorFileUtils class demonstrates practical implementations of both strategies for exploring directory structures in a non-blocking, reactive manner.
2. Traversal Strategies
2.1. Breadth-First Traversal
Breadth-first traversal explores a tree structure level by level, processing all nodes at the current depth before moving to the next level.
2.1.1. Characteristics
Starting Point: Begins with the root directory path
Exploration: Checks if the path is a directory, lists immediate child paths, and emits them level by level
Processing Order: Processes all immediate neighbors before moving deeper
Use Case: Useful when you need to process directories closest to the root first
2.1.2. Example
For a directory structure A → B, C → D, E, the exploration order is:
A (root)
B, C (level 1)
D, E (level 2)
2.2. Depth-First Traversal
Depth-first traversal explores a tree structure by going as deep as possible along each branch before backtracking.
2.2.1. Characteristics
Starting Point: Starts with the root directory path
Exploration: Checks if the path is a directory, lists immediate child paths, and recursively explores each branch completely
Processing Order: Goes as deep as possible before backtracking
Use Case: Useful for fully exploring specific branches before moving on
2.2.2. Example
For a directory structure A → B, C → D, E, the exploration order is:
A (root)
B (first branch)
D, E (explore B’s children completely)
C (backtrack and move to second branch)
3. Project Setup
3.1. Requirements
3.2. Maven Configuration
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.maoudia.lib</groupId>
<artifactId>app</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>maoudia-lib</name>
<description>MAOUDIA LIB</description>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-core</artifactId>
<version>3.6.2</version>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-test</artifactId>
<version>3.6.2</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
4. Implementation
4.1. ReactorFileUtils Class
This utility class provides reactive file operations using Project Reactor.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package com.maoudia;
import jakarta.validation.constraints.NotNull;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.util.annotation.NonNull;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
public class ReactorFileUtils {
@NotNull
public static Flux<Path> expand(@NonNull Path path) { (1)
return Mono.justOrEmpty(path)
.filter(Files::isDirectory)
.expand(ReactorFileUtils::listPaths);
}
@NotNull
public static Flux<Path> expandDeep(@NonNull Path path) { (2)
return Mono.justOrEmpty(path)
.filter(Files::isDirectory)
.expandDeep(ReactorFileUtils::listPaths);
}
@NotNull
public static Flux<Path> listPaths(@NonNull Path path) { (3)
return Mono.justOrEmpty(path)
.filter(Files::isDirectory)
.mapNotNull(directory -> directory.toFile().listFiles())
.flatMapMany(Flux::fromArray)
.map(File::toPath);
}
}
| 1 | Breadth-first traversal: explores directories level by level using expand operator. |
| 2 | Depth-first traversal: explores directories deeply along each branch using expandDeep operator. |
| 3 | Lists all paths within a directory and emits them as a reactive stream. |
4.2. Understanding the Implementation
4.2.1. expand Method
The expand method implements breadth-first traversal:
return Mono.justOrEmpty(path)
.filter(Files::isDirectory)
.expand(ReactorFileUtils::listPaths);Creates a
Monofrom the input pathFilters to ensure it’s a directory
Uses
expandto recursively list paths, processing each level completely before moving deeper
4.2.2. expandDeep Method
The expandDeep method implements depth-first traversal:
return Mono.justOrEmpty(path)
.filter(Files::isDirectory)
.expandDeep(ReactorFileUtils::listPaths);Creates a
Monofrom the input pathFilters to ensure it’s a directory
Uses
expandDeepto recursively explore each branch fully before backtracking
4.2.3. listPaths Method
The listPaths method provides the expansion logic:
return Mono.justOrEmpty(path)
.filter(Files::isDirectory)
.mapNotNull(directory -> directory.toFile().listFiles())
.flatMapMany(Flux::fromArray)
.map(File::toPath);Creates a
Monofrom the pathFilters directories only
Lists all files in the directory
Converts the array to a
FluxMaps each
Fileto aPath
5. Testing
5.1. Test Structure
The tests use StepVerifier from reactor-test to verify the traversal order:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
package com.maoudia;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Flux;
import reactor.test.StepVerifier;
import java.nio.file.Path;
import java.nio.file.Paths;
class ReactorFileUtilsTest {
private static final Path ROOT_DIRECTORY = Paths.get("src/test/resources");
@Test
void expand_ValidPath_ReturnsExpectedPaths() { (1)
Flux<Path> pathFlux = ReactorFileUtils.expand(ROOT_DIRECTORY);
StepVerifier.create(pathFlux)
.expectNextMatches(path -> path.endsWith("resources"))
.expectNextMatches(path -> path.endsWith("file2.txt"))
.expectNextMatches(path -> path.endsWith("file1.txt"))
.expectNextMatches(path -> path.endsWith("subdirectory"))
.expectNextMatches(path -> path.endsWith("emptydirectory"))
.expectNextMatches(path -> path.endsWith("subfile1.txt"))
.expectNextMatches(path -> path.endsWith("subfile2.txt"))
.expectNextMatches(path -> path.endsWith("subsubdirectory"))
.expectNextMatches(path -> path.endsWith("subsubfile1.txt"))
.expectNextMatches(path -> path.endsWith("subsubfile2.txt"))
.verifyComplete();
}
@Test
void expand_NullPath_ReturnsEmptyFlux() { (2)
Flux<Path> pathFlux = ReactorFileUtils.expand(null);
StepVerifier.create(pathFlux)
.verifyComplete();
}
@Test
void expandDeep_ValidPath_ReturnsExpectedPaths() { (3)
Flux<Path> pathFlux = ReactorFileUtils.expandDeep(ROOT_DIRECTORY);
StepVerifier.create(pathFlux)
.expectNextMatches(path -> path.endsWith("resources"))
.expectNextMatches(path -> path.endsWith("file2.txt"))
.expectNextMatches(path -> path.endsWith("file1.txt"))
.expectNextMatches(path -> path.endsWith("subdirectory"))
.expectNextMatches(path -> path.endsWith("subfile1.txt"))
.expectNextMatches(path -> path.endsWith("subfile2.txt"))
.expectNextMatches(path -> path.endsWith("subsubdirectory"))
.expectNextMatches(path -> path.endsWith("subsubfile1.txt"))
.expectNextMatches(path -> path.endsWith("subsubfile2.txt"))
.expectNextMatches(path -> path.endsWith("emptydirectory"))
.verifyComplete();
}
@Test
void expandDeep_NullPath_ReturnsEmptyFlux() { (4)
Flux<Path> pathFlux = ReactorFileUtils.expand(null);
StepVerifier.create(pathFlux)
.verifyComplete();
}
@Test
void listFiles_ValidDirectory_ReturnsExpectedPaths() { (5)
Flux<Path> pathFlux = ReactorFileUtils.listPaths(ROOT_DIRECTORY);
StepVerifier.create(pathFlux)
.expectNextMatches(path -> path.endsWith("file2.txt"))
.expectNextMatches(path -> path.endsWith("file1.txt"))
.expectNextMatches(path -> path.endsWith("subdirectory"))
.expectNextMatches(path -> path.endsWith("emptydirectory"))
.verifyComplete();
}
@Test
void listFiles_NullPath_ReturnsEmptyFlux() { (6)
Flux<Path> pathFlux = ReactorFileUtils.listPaths(null);
StepVerifier.create(pathFlux)
.verifyComplete();
}
}
| 1 | Tests breadth-first traversal, verifying that paths are emitted level by level. |
| 2 | Tests null path handling for expand method, expecting an empty flux. |
| 3 | Tests depth-first traversal, verifying that paths are emitted branch by branch. |
| 4 | Tests null path handling for expandDeep method, expecting an empty flux. |
| 5 | Tests listing immediate children of a directory without recursion. |
| 6 | Tests null path handling for listPaths method, expecting an empty flux. |
5.2. Test Directory Structure
The tests assume the following directory structure in src/test/resources:
resources/
├── file1.txt
├── file2.txt
├── emptydirectory/
└── subdirectory/
├── subfile1.txt
├── subfile2.txt
└── subsubdirectory/
├── subsubfile1.txt
└── subsubfile2.txt5.3. Observing Traversal Differences
Notice the difference in traversal order between the two tests:
Breadth-First (expand):
resources → file2.txt → file1.txt → subdirectory → emptydirectory
→ subfile1.txt → subfile2.txt → subsubdirectory
→ subsubfile1.txt → subsubfile2.txtDepth-First (expandDeep):
resources → file2.txt → file1.txt → subdirectory
→ subfile1.txt → subfile2.txt → subsubdirectory
→ subsubfile1.txt → subsubfile2.txt → emptydirectoryThe key difference: emptydirectory appears earlier in breadth-first (same level as subdirectory) but later in depth-first (after fully exploring subdirectory).
6. Use Cases and Recommendations
6.1. When to Use Breadth-First (expand)
Finding files at specific depths: When you need to process all files at a certain level before going deeper
Memory efficiency: When exploring very deep hierarchies where depth-first might cause stack issues
Immediate neighbor processing: When you need to process files closest to the root first
Load balancing: When distributing work across multiple levels
6.2. When to Use Depth-First (expandDeep)
Complete branch exploration: When you need to fully process one directory tree before moving to siblings
Resource cleanup: When processing requires completing an entire branch before starting another
Path-dependent operations: When operations depend on completing parent-child relationships
Search optimization: When looking for specific files and want to explore paths completely
7. Best Practices
7.1. Error Handling
Add proper error handling to the reactive chain:
ReactorFileUtils.expand(path)
.onErrorResume(IOException.class, e -> {
log.error("Failed to traverse directory", e);
return Flux.empty();
})
.subscribe();7.2. Backpressure Management
For large directory structures, consider using backpressure operators:
ReactorFileUtils.expand(path)
.limitRate(100)
.onBackpressureBuffer(1000)
.subscribe();7.3. Resource Management
Ensure proper resource cleanup when working with file streams:
ReactorFileUtils.expand(path)
.doOnCancel(() -> log.info("Traversal cancelled"))
.doFinally(signalType -> log.info("Traversal completed: {}", signalType))
.subscribe();7.4. Filtering and Transformation
Combine traversal with filtering and transformation:
ReactorFileUtils.expand(path)
.filter(p -> p.toString().endsWith(".txt"))
.map(Path::getFileName)
.subscribe(System.out::println);8. Performance Considerations
8.1. Memory Usage
Breadth-first: Uses more memory as it needs to keep track of all nodes at the current level
Depth-first: Uses less memory but may have deeper recursion stacks
8.2. Processing Speed
Breadth-first: Better for parallel processing of same-level items
Depth-first: Better for sequential processing of complete branches
8.3. Cancellation
Both strategies support cancellation through Reactor’s subscription mechanism:
Disposable subscription = ReactorFileUtils.expand(path)
.subscribe(System.out::println);
subscription.dispose();9. Comparison Summary
| Aspect | Breadth-First (expand) | Depth-First (expandDeep) |
|---|---|---|
Traversal Order | Level by level | Branch by branch |
Processing | Immediate neighbors first | Complete branch exploration |
Memory Usage | Higher (stores level nodes) | Lower (recursive depth) |
Use Case | Level-based operations | Branch-based operations |
Best For | Finding files at specific depths | Complete directory processing |
10. Conclusion
Understanding the difference between breadth-first and depth-first traversal is crucial for efficient reactive directory exploration. Project Reactor’s expand and expandDeep operators provide powerful tools for implementing these strategies in a non-blocking, reactive manner.
Key takeaways:
Use
expand(breadth-first) when you need to process directories level by levelUse
expandDeep(depth-first) when you need to fully explore branches before moving onBoth strategies integrate seamlessly with Reactor’s reactive programming model
Choose the appropriate strategy based on your specific use case and requirements
The complete source code is available on GitHub Gist.