How to Implement a Preprocessor to Transform Property Values Before Use in Owner

Implement the Preprocessor interface and register your class with the @PreprocessorClasses annotation to intercept and modify raw property strings before type conversion occurs.

The Owner configuration library (matteobaccan/owner) provides a powerful extension point that lets you sanitize, normalize, or decrypt property values immediately after they are read from the source but before they are converted to target types. By implementing a Preprocessor to transform property values before use, you can enforce consistent formatting across your entire configuration layer without changing the underlying property files.

Understanding the Preprocessor Pipeline

The transformation mechanism relies on four core components working in sequence. Understanding these files helps you debug and extend the behavior effectively.

Core Components

Component File Path Purpose
Preprocessor interface owner/src/main/java/org/aeonbits/owner/Preprocessor.java Defines the contract with a single process(String) method that receives the raw value and returns the transformed string.
@PreprocessorClasses annotation owner/src/main/java/org/aeonbits/owner/Config.java (lines 58-68) Declares which preprocessor classes apply to a method or an entire config interface.
PreprocessorResolver owner/src/main/java/org/aeonbits/owner/PreprocessorResolver.java Instantiates and orders the processors by reading annotations from the method first, then the declaring interface.
PropertiesInvocationHandler owner/src/main/java/org/aeonbits/owner/PropertiesInvocationHandler.java (lines 97-103) Executes the preProcess method, feeding the raw property value through each resolved processor sequentially.

Execution Flow

When you invoke a method on your config interface, Owner executes these steps:

  1. Read the raw property string from the underlying source (file, system property, environment variable).
  2. Resolve the list of preprocessors via PreprocessorResolver.resolvePreprocessors.
  3. Transform the value by calling process(String) on each preprocessor in order.
  4. Convert the final string to the declared return type (int, boolean, custom object, etc.).

Because preprocessing occurs before type conversion, you can modify the raw text without worrying about format exceptions, provided your preprocessor outputs a string compatible with the target converter.

Implementing a Custom Preprocessor to Transform Property Values

Creating your own preprocessor requires implementing the interface and registering it via annotations. Here is the complete workflow.

Step 1: Create the Preprocessor Class

Implement the org.aeonbits.owner.Preprocessor interface and override the process method. This example normalizes whitespace by collapsing multiple spaces and trimming the result.

package com.example.owner;

import org.aeonbits.owner.Preprocessor;

/**
 * Normalizes whitespace in property values.
 */
public class WhitespaceNormalizer implements Preprocessor {
    @Override
    public String process(String input) {
        if (input == null) {
            return null;
        }
        return input.replaceAll("\\s+", " ").trim();
    }
}

Step 2: Register with @PreprocessorClasses

Apply the @PreprocessorClasses annotation to your config interface or to individual methods. This example applies the preprocessor to the entire interface, affecting every property retrieval.

package com.example.owner;

import org.aeonbits.owner.Config;
import org.aeonbits.owner.Config.PreprocessorClasses;

@PreprocessorClasses({ WhitespaceNormalizer.class })
public interface ApplicationConfig extends Config {

    @DefaultValue("   admin   user   ")
    String adminUser();  // Returns "admin user"

    @DefaultValue("  127.0.0.1  ")
    String serverHost(); // Returns "127.0.0.1"
}

Step 3: Method-Level vs Interface-Level Registration

You can scope preprocessors narrowly to specific methods or broadly to the entire configuration interface. Method-level annotations override interface-level behavior for that specific property.

@PreprocessorClasses({ GlobalPreprocessor.class }) // Applied to all methods
public interface MixedConfig extends Config {

    @PreprocessorClasses({ SpecificPreprocessor.class }) // Applied first
    @DefaultValue("value")
    String specificProperty(); // Processing order: SpecificPreprocessor → GlobalPreprocessor

    String normalProperty();   // Only GlobalPreprocessor applies
}

Preprocessor Execution Order

When both method-level and interface-level @PreprocessorClasses annotations are present, Owner concatenates the lists in a specific sequence defined in PreprocessorResolver.resolvePreprocessors.

The execution order is:

  1. Method-level preprocessors (declared directly on the invoked method)
  2. Interface-level preprocessors (declared on the config interface)

This ordering ensures that specific, local transformations occur before broader, global ones. For example, if you have a method-specific decryption preprocessor and an interface-wide trim preprocessor, the decryption runs first, then the trimming occurs.

Real-World Examples from the Owner Test Suite

The Owner library includes comprehensive tests demonstrating practical preprocessor implementations. The PreprocessorTest class in owner/src/test/java/org/aeonbits/owner/PreprocessorTest.java defines three useful examples as static inner classes:

  • Trim: Removes leading and trailing whitespace from property values.
  • ToLowerCase: Converts the entire string to lowercase for case-insensitive configuration handling.
  • SkipInlineComments: Strips inline comments (text following # or !) commonly found in property files.

These examples demonstrate that preprocessors can handle everything from simple string manipulation to complex parsing logic before Owner attempts type conversion.

Summary

Implementing a Preprocessor to transform property values before use in Owner involves these key steps:

  • Implement the org.aeonbits.owner.Preprocessor interface and override the process(String) method to define your transformation logic.
  • Register your preprocessor class using the @PreprocessorClasses annotation on either the config interface (global scope) or individual methods (local scope).
  • Understand that method-level preprocessors execute before interface-level ones, allowing you to chain specific and global transformations.
  • Reference the source files Preprocessor.java, Config.java, PreprocessorResolver.java, and PropertiesInvocationHandler.java to understand the internal execution pipeline.

Frequently Asked Questions

Can I apply multiple preprocessors to the same property?

Yes. The @PreprocessorClasses annotation accepts an array of classes, and they execute in the order declared. Additionally, you can combine method-level and interface-level annotations to create complex transformation chains. For example, you might declare { Trim.class, ToLowerCase.class } to first remove whitespace and then convert to lowercase.

What happens if my preprocessor returns null?

If your process method returns null, Owner passes that null value to the type converter. Depending on the target type, this may result in a null value for object types or a conversion exception for primitive types. Always ensure your preprocessor handles null inputs gracefully, typically by returning null unchanged or providing a default string representation.

Can preprocessors access the property key or configuration metadata?

No. The Preprocessor interface only receives the raw property value as a String. It does not have access to the property key, the method being invoked, or other metadata. If you need context-aware transformations, consider implementing a custom Converter instead, which receives the Method object and can access annotations and other metadata.

Are preprocessors instantiated once or on every property access?

Owner instantiates preprocessor classes once per configuration interface creation and caches the instances. The PreprocessorResolver creates the instances when resolving the preprocessor list, and these same instances are reused for all subsequent property retrievals on that config object. Therefore, preprocessors should be stateless or thread-safe if they maintain any internal state.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →