How to Implement a Custom ZooKeeper Loader from the Owner-Extras Module

Implement the Loader interface from org.aeonbits.owner.loaders, handle the zookeeper URI scheme in accept(URI), extract properties using Apache Curator in load(Properties, URI), and register your class via ConfigFactory.registerLoader() before creating the configuration instance.

The owner-extras module in the matteobaccan/owner repository ships with a reference ZooKeeperLoader that demonstrates how to bridge Owner’s type-safe configuration interfaces with Apache ZooKeeper. Creating a custom ZooKeeper loader lets you customize node traversal, connection retry policies, and serialization while preserving the clean @Sources annotation-driven configuration model.

Understanding the Loader Contract in Owner

Every configuration loader in the Owner framework must implement the Loader interface located at owner/src/main/java/org/aeonbits/owner/loaders/Loader.java. This contract defines three methods that control how Owner resolves and reads external configuration sources:

  • boolean accept(URI uri) – Returns true only for URIs your loader can handle (e.g., scheme zookeeper).
  • void load(Properties result, URI uri) – Connects to the external source, reads data, and populates the supplied Properties object.
  • String defaultSpecFor(String urlPrefix) – Optionally returns a default URL when none is declared in @Sources; return null if unsupported.

The Properties object passed into load acts as a mutable map; keys and values you insert become accessible through the typed configuration interface.

Anatomy of the Built-In ZooKeeperLoader

The reference implementation in owner-extras/src/main/java/org/aeonbits/owner/loaders/ZooKeeperLoader.java demonstrates the canonical pattern for ZooKeeper integration. Study its structure before extending it:

  1. Scheme detection – The accept method checks uri.getScheme().equals("zookeeper").
  2. Client construction – The private getClient(URI) method builds a Curator CuratorFramework using the host and port from the URI.
  3. Connection lifecycle – Inside load, the client starts with client.start() and blocks until connected using client.blockUntilConnected(timeout, TimeUnit.SECONDS), respecting the system property owner.zookeeper.connection.timeout.seconds (default 30 seconds).
  4. Node iteration – The loader lists children of the base path (uri.getPath), retrieves data for each child via client.getData().forPath(), and stores entries in result.put(key, value).
  5. Resource cleanup – The client closes in a finally block to prevent connection leaks.

If the base path does not exist, the loader returns an empty Properties map, causing all configuration methods to yield null.

Creating Your Custom ZooKeeper Loader

To build a specialized loader—perhaps one that supports recursive node traversal or custom authentication—follow this implementation pattern.

Step 1: Implement the Loader Interface

Create a new class that implements org.aeonbits.owner.loaders.Loader. Import org.apache.curator.framework.CuratorFramework and CuratorFrameworkFactory to handle ZooKeeper connectivity.

package com.example.config;

import org.aeonbits.owner.loaders.Loader;
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.CuratorFrameworkFactory;
import org.apache.curator.utils.ZKPaths;
import java.io.IOException;
import java.net.URI;
import java.util.Properties;

public class RecursiveZooKeeperLoader implements Loader {
    private static final String SCHEME = "zookeeper";

    @Override
    public boolean accept(URI uri) {
        return SCHEME.equals(uri.getScheme());
    }
    
    // ... remaining methods
}

Step 2: Define Scheme Acceptance

The accept method must strictly filter URIs to avoid conflicts with other loaders. Return true only when the scheme matches your target protocol.

@Override
public boolean accept(URI uri) {
    return "zookeeper".equals(uri.getScheme());
}

Step 3: Implement Property Loading

In load(Properties result, URI uri), instantiate the Curator client, connect, and populate the properties map. Always wrap the connection in a try-finally block to ensure client.close() runs.

@Override
public void load(Properties result, URI uri) throws IOException {
    String connectString = uri.getHost() + (uri.getPort() == -1 ? "" : ":" + uri.getPort());
    CuratorFramework client = CuratorFrameworkFactory.newClient(connectString, 
        (retry, elapsed, sleeper) -> false);
    
    try {
        client.start();
        client.blockUntilConnected(30, java.util.concurrent.TimeUnit.SECONDS);
        
        String basePath = uri.getPath();
        for (String child : client.getChildren().forPath(basePath)) {
            String fullPath = ZKPaths.makePath(basePath, child);
            byte[] data = client.getData().forPath(fullPath);
            if (data != null) {
                result.put(child, new String(data));
            }
        }
    } catch (Exception e) {
        throw new IOException("Failed to load from ZooKeeper: " + uri, e);
    } finally {
        client.close();
    }
}

Step 4: Optional Default URL Support

Override defaultSpecFor only if your loader should supply a fallback URL when the @Sources annotation is omitted. The built-in ZooKeeperLoader returns null, forcing explicit source declaration.

@Override
public String defaultSpecFor(String urlPrefix) {
    return null; // No default ZooKeeper ensemble
}

Registering and Consuming the Loader

Before creating a configuration instance, you must register your custom loader with a ConfigFactory. The factory located at owner/src/main/java/org/aeonbits/owner/ConfigFactory.java maintains a registry of loaders and dispatches to them based on URI schemes.

import org.aeonbits.owner.ConfigFactory;

// Define the configuration contract
@Sources("zookeeper://127.0.0.1:2181/appConfig")
public interface AppConfig extends Config {
    String databaseUrl();
    Integer connectionPoolSize();
}

// Register the custom loader and instantiate
ConfigFactory factory = ConfigFactory.newInstance();
factory.registerLoader(new RecursiveZooKeeperLoader());

AppConfig cfg = factory.create(AppConfig.class);
System.out.println(cfg.databaseUrl());

The test suite in owner-extras/src/test/java/org/aeonbits/owner/loaders/ZooKeeperLoaderTest.java demonstrates this registration flow using an embedded ZooKeeper server, verifying that properties correctly map from ZNodes to interface methods.

Summary

  • Implement org.aeonbits.owner.loaders.Loader and handle the zookeeper scheme in accept(URI).
  • Use Apache Curator (CuratorFramework) to connect, read children nodes, and populate the Properties map inside load(Properties, URI).
  • Close the Curator client in a finally block to prevent connection leaks.
  • Register your implementation via ConfigFactory.registerLoader() before calling create(Class).
  • Return null from defaultSpecFor unless you require a default ZooKeeper connection string.

Frequently Asked Questions

What interface must a custom ZooKeeper loader implement?

Any custom loader must implement org.aeonbits.owner.loaders.Loader, defining accept(URI), load(Properties, URI), and defaultSpecFor(String). This interface allows Owner to delegate URI resolution and property extraction to your code.

How do I register my custom loader with the Owner framework?

Call ConfigFactory.newInstance().registerLoader(new YourLoader()) prior to creating the configuration interface. The factory caches registered loaders and routes @Sources URIs to the first loader whose accept method returns true.

Can I configure the ZooKeeper connection timeout in my custom loader?

Yes. You can respect the standard property owner.zookeeper.connection.timeout.seconds (default 30 seconds) as shown in the reference ZooKeeperLoader, or implement custom timeout logic inside load by adjusting the parameters passed to client.blockUntilConnected().

What happens if the ZooKeeper base path does not exist?

The loader returns an empty Properties map. Consequently, all methods on the configuration interface return null (or default values if specified via @DefaultValue), and no exception is thrown during proxy creation.

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 →