Hold the Side Effects, Please
Let’s imagine a simple task: parsing a CSV file and logging its content.
Sample CSV:
Bicycle,135445
Book,0161684
Phone,798746
At first glance, the task seems trivial. We might come up with this straightforward Java-based solution:
@Slf4j
class ProductParser {
private static final String SEPARATOR = ",";
void parseProducts(List<String> csvLines) {
csvLines.stream()
.map(this::parseProduct)
.forEach(this::log);
}
private Product parseProduct(String line) {
String[] parts = line.split(SEPARATOR);
String name = parts[0].trim();
String sku = parts[1].trim();
return new Product(name, sku);
}
private void log(Product product) {
log.info("Name: {}, SKU: {}", product.name(), product.sku());
}
}
This works, but during the code review, it was rejected. Why?
Side Effects and the Issues They Cause
The main problem lies in side effects. A side effect happens when a method modifies the state outside its own context, making its behavior less predictable. In this case, logging is the problem because:
- Testing Becomes Challenging: Testing logging streams or outputs is non-trivial and often brittle.
- Single Responsibility Principle (SRP): The method does two things — parsing and logging — violating the SRP.
- Misleading Signature: The method’s name and signature suggest parsing, but it also modifies the log, an external state.
Why Pure Functions Matter
To address these problems, we need to isolate the side effect and make the method pure.
What is a pure function?
- It always returns the same result for the same input.
- It depends only on the arguments passed to it.
- It doesn’t modify external state.
Applying these principles, we rework the method:
List<Product> parseProducts(List<String> csvLines) {
return csvLines.stream()
.map(this::parseProduct)
.toList();
}
Now, the method only parses the CSV lines and returns the result, leaving logging or any other side effect to the calling code.
Testing the Pure Function
By isolating the parsing logic, we can now easily write unit tests to verify correctness:
@Test
void parseProducts_shouldReturnParsedProducts() {
List<String> csvLines = List.of("Bicycle,135445", "Book,0161684");
List<Product> products = parser.parseProducts(csvLines);
assertEquals(2, products.size());
assertEquals("Bicycle", products.get(0).name());
assertEquals("135445", products.get(0).sku());
}
The logging functionality can be tested separately if necessary, but it’s decoupled from the parsing logic.
The Result with this update:
- The method is testable: No external dependencies complicate testing.
- The SRP is respected: Parsing and logging are handled separately.
- Clear behavior: The method signature accurately reflects its functionality.
Conclusion
Side effects may seem harmless at first, but they complicate your code, making it harder to test, maintain, and reason about. By isolating side effects and embracing pure functions, your code becomes predictable, testable, and honest about its behavior.