Auditing Complex Data with Optics
A Real-World Deep Dive: The Power of Optics
In modern software, we often work with complex, nested data structures. Performing a seemingly simple task—like "find and decode all production database passwords"—can lead to messy, error-prone code with nested loops, if
statements, and manual type casting.
This tutorial demonstrates how to solve a sophisticated, real-world problem elegantly using the full power of higher-kinded-j optics. We'll build a single, declarative, type-safe optic that performs a deep, conditional data transformation.
All the example code can be found in the org.higherkindedj.example.optics
package in the HKJ-Examples.
🎯 The Challenge: A Conditional Config Audit
Imagine you're responsible for auditing application configurations. Your task is:
Find every encrypted database password, but only for applications deployed to the Google Cloud Platform (
gcp
) that are running in thelive
environment. For each password found, decode it from Base64 into a rawbyte[]
for an audit service.
This single sentence implies several operations:
- Deep Traversal: Navigate from a top-level config object down into a list of settings.
- Filtering: Select only settings of a specific type (
EncryptedValue
). - Conditional Logic: Apply this logic only if the top-level config meets specific criteria (
gcp
andlive
). - Data Transformation: Decode the Base64 string into another type (
byte[]
).
Doing this imperatively is a recipe for complexity. Let's build it with optics instead.
🛠️ The Four Tools for the Job
Our solution will compose the four primary optic types, each solving a specific part of the problem.
1. Lens: The Magnifying Glass 🔎
A Lens
provides focused access to a field within a product type (like a Java record
). We'll use lenses to look inside our configuration objects.
AppConfigLenses.settings()
: Zooms from anAppConfig
to itsList<Setting>
.SettingLenses.value()
: Zooms from aSetting
to itsSettingValue
.
2. Iso: The Universal Translator 🔄
An Iso
(Isomorphism) defines a lossless, two-way conversion between two types. It's perfect for handling different representations of the same data.
DeploymentTarget <-> String
: We model our deployment target as a structured record but recognize it's isomorphic to a raw string like"gcp|live"
. AnIso
lets us switch between these representations.String <-> byte[]
: Base64 is just an encoded representation of a byte array. AnIso
is the perfect tool for handling this encoding and decoding.
3. Prism: The Safe Filter 🔬
A Prism
provides focused access to a specific case within a sum type (like a sealed interface
). It lets us safely attempt to "zoom in" on one variant, failing gracefully if the data is of a different kind.
SettingValuePrisms.encryptedValue()
: This is our key filter. It will look at aSettingValue
and only succeed if it's theEncryptedValue
variant.
4. Traversal: The Bulk Operator 🗺️
A Traversal
lets us operate on zero or more targets within a larger structure. It's the ideal optic for working with collections.
AppConfigTraversals.settings()
: This generated optic gives us a single tool to go from anAppConfig
to everySetting
inside its list.
✨ Composing the Solution
Here's how we chain these optics together. To create the most robust and general-purpose optic (a Traversal
), we convert each part of our chain into a Traversal
using .asTraversal()
before composing it. This ensures type-safety and clarity throughout the process.
The final composed optic has the type Traversal<AppConfig, byte[]>
and reads like a declarative path: AppConfig -> (Filter for GCP/Live) -> each Setting -> its Value -> (Filter for Encrypted) -> the inner String -> the raw bytes
// Inside ConfigAuditExample.java
// A. First, create a Prism to act as our top-level filter.
Prism<AppConfig, AppConfig> gcpLiveOnlyPrism = Prism.of(
config -> {
String rawTarget = DeploymentTarget.toRawString().get(config.target());
return "gcp|live".equals(rawTarget) ? Optional.of(config) : Optional.empty();
},
config -> config // The 'build' function is just identity
);
// B. Define the main traversal path to get to the data we want to audit.
Traversal<AppConfig, byte[]> auditTraversal =
AppConfigTraversals.settings() // Traversal<AppConfig, Setting>
.andThen(SettingLenses.value().asTraversal()) // Traversal<AppConfig, SettingValue>
.andThen(SettingValuePrisms.encryptedValue().asTraversal()) // Traversal<AppConfig, EncryptedValue>
.andThen(EncryptedValueLenses.base64Value().asTraversal()) // Traversal<AppConfig, String>
.andThen(EncryptedValueIsos.base64.asTraversal()); // Traversal<AppConfig, byte[]>
// C. Combine the filter and the main traversal into the final optic.
Traversal<AppConfig, byte[]> finalAuditor = gcpLiveOnlyPrism.asTraversal().andThen(auditTraversal);
// D. Using the final optic is now trivial.
// We call a static helper method from our Traversals utility class.
List<byte[]> passwords = Traversals.getAll(finalAuditor, someConfig);
When we call Traversals.getAll(finalAuditor, config)
, it performs the entire, complex operation and returns a simple List<byte[]>
containing only the data we care about.
🚀 Why This is a Powerful Approach
- Declarative & Readable: The optic chain describes what data to get, not how to loop and check for it. The logic reads like a path, making it self-documenting.
- Composable & Reusable: Every optic, and every composition, is a reusable component. We could reuse
gcpLiveOnlyPrism
for other tasks, or swap out the finalbase64
Iso to perform a different transformation. - Type-Safe: The entire operation is checked by the Java compiler. It's impossible to, for example, try to decode a
StringValue
as if it were encrypted. A mismatch in the optic chain results in a compile-time error, not a runtimeClassCastException
. - Architectural Purity: By having all optics share a common abstract parent (
Optic
), the library provides universal, lawful composition while allowing for specialized, efficient implementations.
🧠 Taking It Further
This example is just the beginning. Here are some ideas for extending this solution into a real-world application:
- Safe Decoding with
Validated
: TheBase64.getDecoder().decode()
can throw anIllegalArgumentException
. Instead of anIso
, create anAffineTraversal
(an optionalPrism
) that returns aValidated<String, byte[]>
, separating successes from failures gracefully. - Data Migration with
modify
: What if you need to re-encrypt all passwords with a new algorithm? The samefinalAuditor
optic can be used with a modify function from theTraversals
utility class. You'd write a functionbyte[] -> byte[]
and apply it:
// A function that re-encrypts the raw password bytes
Function<byte[], byte[]> reEncryptFunction = (oldBytes) -> newCipher.encrypt(oldBytes);
// Use the *exact same optic* to update the config in-place
AppConfig updatedConfig = Traversals.modify(finalAuditor, reEncryptFunction, originalConfig);
- More Complex Filters: Create an optic that filters for deployments on either
gcp
oraws
but only in thelive
environment. The composable nature of optics makes building up these complex predicate queries straightforward. - Configuration Validation: Use the same optics to validate your configuration. You could compose a traversal that finds all
IntValue
settings with the key"server.port"
and use.getAll()
to check if their values are within a valid range (e.g., > 1024).