This is the multi-page printable view of this section. Click here to print.

Return to the regular view of this page.

Contributor Guide

For contributors to the Lucille codebase — project structure, setup, and coding standards.

This section is for developers contributing code back to the Lucille project. It covers how the project is structured, how to set up your development environment, and the coding conventions used throughout the codebase.

If you are writing components for your own project rather than contributing to Lucille itself, see the Component Developer Guide instead.

1 - Project Structure and Build

The multi-module Maven project structure, how the build works, and how to use custom components with Lucille.

Overview

Lucille is a multi-module Maven project. The module hierarchy is:

lucille (root aggregator)
├── lucille-parent          # parent POM: dependency versions, plugin config, build profiles
├── lucille-bom             # Bill of Materials: version-aligned dependency declarations
├── lucille-core            # the framework itself: Runner, Worker, Indexer, Pipeline, Document, Stages, Connectors
├── lucille-plugins/        # optional modules with heavy or specialized dependencies (tika, pinecone, weaviate, jlama, parquet, ocr, video, entity-extraction, api)
└── lucille-examples/       # runnable example projects demonstrating common ingestion patterns (not published to Maven Central)

The Modules

lucille-parent

The parent POM that all other modules inherit from. It defines:

  • Java version (17)
  • Dependency versions for all third-party libraries (Jackson, Kafka, Solr, OpenSearch, Elasticsearch, AWS SDK, etc.) as properties
  • Dependency management section that pins versions so child modules don’t need to specify versions
  • Plugin configuration for compilation, testing, Javadoc generation, source JARs, and GPG signing
  • Build profiles (e.g., the deploy profile for publishing to Maven Central with GPG signing)
  • Distribution management pointing to Sonatype OSSRH for releases

When should you be concerned with lucille-parent? When you need to:

  • Update a third-party dependency version (change the property in lucille-parent)
  • Add a new dependency that multiple modules will use (add it to dependencyManagement)
  • Change build plugin configuration (compiler settings, test runner, etc.)
  • Prepare a release (the version number lives here)

Most day-to-day development (writing stages, connectors, tests) does not require touching lucille-parent.

lucille-bom

The Bill of Materials POM. It declares all Lucille modules (lucille-core, lucille-pinecone, lucille-tika, etc.) with their versions aligned to ${project.version}. External projects that depend on Lucille import the BOM in their dependencyManagement section, which lets them declare Lucille dependencies without specifying versions:

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>com.kmwllc</groupId>
      <artifactId>lucille-bom</artifactId>
      <version>0.9.0</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

<dependencies>
  <dependency>
    <groupId>com.kmwllc</groupId>
    <artifactId>lucille-core</artifactId>
    <!-- version inherited from BOM -->
  </dependency>
  <dependency>
    <groupId>com.kmwllc</groupId>
    <artifactId>lucille-tika</artifactId>
    <!-- version inherited from BOM -->
  </dependency>
</dependencies>

The BOM ensures that all Lucille modules in a project are at the same version, preventing subtle incompatibilities.

lucille-core

The framework itself. Contains:

  • The core architecture: Runner, Worker, Indexer, Publisher, Pipeline, Document
  • The messenger abstractions: LocalMessenger, TestMessenger, KafkaWorkerMessenger, etc.
  • Built-in Stages (field manipulation, regex, date parsing, text operations, database enrichment, HTTP enrichment, scripting, etc.)
  • Built-in Connectors (FileConnector, DatabaseConnector, SolrConnector, etc.)
  • Built-in Indexers (SolrIndexer, OpenSearchIndexer, ElasticsearchIndexer, CSVIndexer)
  • The SPEC validation system
  • Test infrastructure (TestMessenger, RunType.TEST)

This is the only module required for a minimal Lucille deployment. If your pipeline doesn’t need Tika, Pinecone, OCR, etc., you only need lucille-core.

lucille-plugins

An aggregator POM containing optional modules. Each plugin:

  • Has its own pom.xml with lucille-core as a provided dependency
  • Brings in one or more heavy third-party libraries (Tika, Tesseract, JLama, Pinecone SDK, etc.)
  • Produces its own JAR artifact
  • Is published independently to Maven Central

The provided scope on lucille-core means the plugin compiles against core but does not bundle it — at runtime, core is expected to already be on the classpath. This prevents version conflicts when multiple plugins are used together.

When to create a new plugin module:

  • The component depends on a large library (>10MB, or with many transitive dependencies)
  • The dependency is licensed in a way that not all users would accept
  • The component is specialized enough that most users won’t need it
  • Including it in core would bloat the core JAR or cause transitive dependency conflicts

lucille-examples

Runnable example projects that demonstrate common ingestion patterns. Each example:

  • Has its own pom.xml importing the lucille-bom
  • Depends on lucille-core and whichever plugins it needs
  • Includes a conf/ directory with HOCON config files
  • Often includes a scripts/ directory with shell scripts for running
  • Uses maven-dependency-plugin to copy runtime dependencies to target/lib/

Examples are not published to Maven Central (<maven.deploy.skip>true</maven.deploy.skip>). They exist purely as reference implementations and starting points for new projects.


Version Management

The project version is specified in lucille-parent/pom.xml:

<version>0.9.0-SNAPSHOT</version>

All other modules inherit this version via their <parent> declaration. The version appears explicitly in each module’s parent reference (Maven requires this), but the single source of truth is lucille-parent.

SNAPSHOT vs. release: During development, the version ends with -SNAPSHOT. When a release is cut, the version is updated to remove -SNAPSHOT (e.g., 0.9.0), the release is built and published, and then the version is bumped to the next SNAPSHOT (e.g., 0.10.0-SNAPSHOT).

Updating the version: Because Maven requires the version to be explicit in each module’s <parent> block, updating the version requires changing it in every pom.xml in the project. This is typically done with the Maven versions plugin:

mvn versions:set -DnewVersion=0.9.0

How the Build Works

Building the entire project

From the root directory:

mvn clean install

This builds all modules in dependency order: lucille-parent → lucille-bom → lucille-core → lucille-plugins (each plugin) → lucille-examples (each example). Each module produces a JAR artifact installed to the local Maven repository.

Building a single module

mvn clean install -pl lucille-core

Or for a plugin:

mvn clean install -pl lucille-plugins/lucille-tika

What artifacts are produced

  • lucille-core: target/lucille.jar — a thin JAR containing only Lucille’s own classes (named lucille.jar via finalName in the POM). Runtime dependencies are copied to target/lib/. To run Lucille, both must be on the classpath: -cp 'target/lucille.jar:target/lib/*' (or simply -cp 'target/lib/*' since the lucille JAR is also copied there).
  • Each plugin: a shaded (fat) JAR that bundles the plugin’s own dependencies into a single artifact. This avoids classpath conflicts when multiple plugins are used together. Plugins that produce shaded JARs include: lucille-tika, lucille-pinecone, lucille-weaviate, lucille-jlama, lucille-parquet, lucille-ocr, lucille-video, and lucille-api.
  • Each example: a thin JAR plus target/lib/ containing all runtime dependencies (via maven-dependency-plugin copy-dependencies).

Thin JAR vs. Shaded JAR

ModuleJAR TypeWhy
lucille-coreThin JAR + target/lib/Core is always on the classpath alongside its dependencies. No need to shade.
Plugins (tika, pinecone, etc.)Shaded (fat) JARPlugins bundle their heavyweight dependencies to avoid version conflicts with core or other plugins. A single plugin JAR can be dropped onto the classpath without worrying about transitive dependency management.
ExamplesThin JAR + target/lib/Examples are runnable projects, not libraries. Dependencies are copied for easy classpath setup.

When running Lucille from the command line, the classpath typically looks like:

java -Dconfig.file=config.conf -cp 'lucille-core/target/lucille.jar:lucille-core/target/lib/*:lucille-plugins/lucille-tika/target/lucille-tika-*.jar' com.kmwllc.lucille.core.Runner

Or more simply from an example directory where everything is in target/lib/:

java -Dconfig.file=conf/my-config.conf -cp 'target/lib/*' com.kmwllc.lucille.core.Runner

Running an example

After building, from an example’s directory:

java -Dconfig.file=conf/file-to-file-example.conf -cp 'target/lib/*' com.kmwllc.lucille.core.Runner

Using Custom Connectors and Stages with Lucille

Writing a custom component in your own project

If you write your own Connector or Stage in a separate Maven project, you need to:

  1. Depend on lucille-core (and any plugins you need):
<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>com.kmwllc</groupId>
      <artifactId>lucille-bom</artifactId>
      <version>0.9.0</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

<dependencies>
  <dependency>
    <groupId>com.kmwllc</groupId>
    <artifactId>lucille-core</artifactId>
  </dependency>
</dependencies>
  1. Implement your Stage or Connector following the standard patterns (SPEC declaration, constructor calling super(config), etc.)

  2. Build your project to produce a JAR.

  3. Include your JAR on the classpath when running Lucille. The simplest approach is to copy your JAR into the target/lib/ directory alongside Lucille’s JARs.

  4. Reference your class in the config using its fully qualified class name:

stages: [
  {
    class: "com.mycompany.lucille.stages.MyCustomStage"
    myParam: "value"
  }
]

Lucille instantiates Stages, Connectors, and Indexers reflectively using the class property in the config. As long as the class is on the classpath and follows the expected constructor signature, it will work. There is no registration step, no plugin manifest, no service loader — just put the class on the classpath and reference it by name.

The classpath is the plugin mechanism

Lucille’s “plugin system” is simply the JVM classpath. To add a capability:

  1. Write a class that implements the appropriate interface or extends the appropriate base class.
  2. Put the compiled class (in a JAR) on the classpath.
  3. Reference it by fully qualified class name in the config.

This is why the built-in plugins are structured as separate JARs rather than using a framework-specific plugin API. A plugin JAR is just a JAR with classes that happen to extend Lucille’s base classes. Your custom code works exactly the same way.


Project Layout Conventions

Source code layout

Each module follows standard Maven conventions:

lucille-core/
├── src/
│   ├── main/
│   │   ├── java/com/kmwllc/lucille/
│   │   │   ├── core/          # Runner, Worker, Indexer, Publisher, Pipeline, Document, Stage
│   │   │   ├── connector/     # built-in Connectors
│   │   │   ├── indexer/       # IndexerFactory and related utilities
│   │   │   ├── message/       # Messenger interfaces and implementations
│   │   │   ├── stage/         # built-in Stages
│   │   │   └── util/          # utilities (ConfigUtils, LogUtils, etc.)
│   │   └── resources/
│   │       ├── reference.conf           # default config values
│   │       └── validConfigProperties.conf  # config validation rules
│   └── test/
│       ├── java/com/kmwllc/lucille/     # test classes
│       └── resources/                    # test config files
└── pom.xml

Where to put new code

  • A new general-purpose Stagelucille-core/src/main/java/com/kmwllc/lucille/stage/
  • A new Connectorlucille-core/src/main/java/com/kmwllc/lucille/connector/
  • A Stage or Connector with heavy dependencies → new module under lucille-plugins/
  • A new example → new module under lucille-examples/
  • Test configssrc/test/resources/ in the relevant module

Test JARs

lucille-core produces a test JAR (lucille-core-0.9.0-SNAPSHOT-tests.jar) that is available to other modules for testing. The examples module depends on this test JAR, which provides test utilities and base classes for writing integration tests against the framework.


Publishing to Maven Central

Lucille is published to Maven Central via Sonatype OSSRH. The deploy profile in lucille-parent activates GPG signing and source/Javadoc JAR generation:

mvn clean deploy -Ddeploy

This publishes lucille-core, lucille-bom, and all plugin modules. Examples are excluded from publishing (maven.deploy.skip=true).


Summary

ModulePurposePublished?
lucille-parentVersion management, dependency versions, build configYes
lucille-bomVersion-aligned dependency declarations for consumersYes
lucille-coreThe framework (all core components)Yes
lucille-plugins/*Optional modules with heavy dependenciesYes (each)
lucille-examples/*Runnable reference implementationsNo

The key things a developer needs to know:

  1. lucille-core is the only required dependency. Everything else is optional.
  2. Plugins use provided scope for core. They compile against it but don’t bundle it.
  3. The classpath is the plugin mechanism. Put your JAR on the classpath, reference the class by name in config.
  4. Version is in lucille-parent. All modules inherit it.
  5. Examples show the patterns. Start from an example when building a new ingest project.
  6. The BOM simplifies dependency management. Import it and you don’t need to specify Lucille module versions.

2 - Setup & Standards

Coding standards for Lucille and how to set up for local development.

Local Developer Setup

Prerequisite(s):

  • IntelliJ application installed on machine
  • Java project

Setting up Google Code Formatting Scheme

  • Make sure that Intellij is open
  • Go to the following link: styleguide/intellij-java-google-style.xml at gh-pages · google/styleguide
  • Download the .xml file
  • Open the file in an editor of your choice
  • Navigate to the <option …> tag with name ‘Right Margin’ and edit the value to be 132 (it should default as 100)
  • Save the file
  • In Intellij IDEA, navigate to Settings | Preferences → Code Style → Editor → Java
  • Click on the gear icon on the right panel and drill down to the option Import Scheme and then to Intellij IDEA Code Style XML
  • In the file explorer that opens, navigate to where you stored the aforementioned .xml file we downloaded
  • After selecting the file, you should see a pop-up allowing you to name the scheme; select a name and click ‘Okay’
  • Click ‘Apply’ in the Settings panel
  • Restart the IDE; You can use the ‘Reformat Code’ option to apply the plug-in on your code

Excluding Non-Java Files

Assuming that we don’t want to auto-format non-java files via a directory level ‘Reformat Code’ option, we need to exclude all other files from being reformatted

  • Navigate to Settings | Preferences in Intellij IDEA

  • Navigate to Editor → Code Style

  • Click on the tab on the right window labeled ‘Formatter’

  • In the ‘Do Not Format’ text box, paste the following and click ‘Apply'

    *.{yml,xml,md,json,yaml,jsonl,sql}

  • A restart of Intellij may be required to see changes

This method may prove to be too complicated, especially when new file types are added to the codebase, therefore, consider the following, simpler method instead:

  • When clicking on ‘Reformat Code’ at the directory level, a window will pop up
  • Under the filter sections in the window, select the ‘File Mask(s)’ option and set the value to ‘*.java’
  • This will INCLUDE all .java files in your reformatting

Eclipse Users

Eclipse import conf .xml files

The linked post details some useful information for how Eclipse users can use the same .xml for their code formatting on Eclipse IDE.

3 - Coding Conventions

Formatting, naming, class structure, Javadoc, and test conventions used in the Lucille codebase.

Lucille follows conventions broadly consistent with the Google Java Style Guide, with some deviations noted below. There is no enforced checkstyle or formatter plugin in the build — conventions are maintained through code review.


Formatting

Indentation: 2 spaces. All source files use 2-space indentation, consistent with Google style. No tabs.

Line length: No strict enforced limit, but lines are generally kept under 120 characters. Long lines (especially in constructors with many parameters or complex conditionals) are broken at natural points.

Braces: Opening braces on the same line as the statement (K&R style). Closing braces on their own line. Single-statement if blocks still use braces in most cases, though there are occasional instances of brace-less single-line if statements (a minor deviation from strict Google style).

if (doc == null) {
  commitOffsetsAndRemoveCounter(null);
  continue;
}

Blank lines: One blank line between methods. One blank line between logical sections within a method. No multiple consecutive blank lines (though occasional double blanks appear in older code).


Naming

Classes: PascalCase. Stage names describe what they do: CopyFields, DeleteFields, RenameFields, ChunkText, EmitNestedChildren. Connectors are named for their source: FileConnector, DatabaseConnector, SolrConnector. Indexers are named for their destination: SolrIndexer, OpenSearchIndexer, PineconeIndexer.

Methods: camelCase. Standard Java conventions. Getters use get prefix (getId(), getString()). Boolean getters use is or has prefix (isDropped(), hasChildren(), has()).

Constants: UPPER_SNAKE_CASE for static final fields that are true constants:

public static final String ID_FIELD = "id";
public static final String RUNID_FIELD = "run_id";
public static final int DEFAULT_BATCH_SIZE = 100;

Instance fields: camelCase with private final where possible. Fields are declared at the top of the class, after constants and the logger:

public class CopyFields extends Stage {
  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());

  public static final Spec SPEC = SpecBuilder.stage()...;

  private final Map<String, Object> fieldMapping;
  private final UpdateMode updateMode;
  private final boolean isNested;

Local variables: camelCase. Short, descriptive names. Loop variables use standard conventions (i, doc, node, entry).

Packages: All under com.kmwllc.lucille. Sub-packages by function: core, stage, connector, indexer, message, util.


Class Structure

The standard ordering within a class is:

  1. static final constants (UPPER_SNAKE_CASE)
  2. Logger declaration
  3. public static final Spec SPEC declaration
  4. Instance fields
  5. Constructor
  6. start() method (for Stages)
  7. processDocument() or execute() (the main logic)
  8. stop() or close() (cleanup)
  9. Private helper methods

This ordering is consistent across Stages, Connectors, and Indexers.


Logger Conventions

Two logger patterns are used:

Standard class logger — for operational messages:

private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());

or equivalently:

private static final Logger log = LoggerFactory.getLogger(FileConnector.class);

Both patterns appear in the codebase. The MethodHandles.lookup().lookupClass() pattern avoids hardcoding the class name (useful when copy-pasting). The explicit class reference is more common in older code.

DocLogger — for per-document lifecycle events (used in core components, not in stages):

private static final Logger docLogger = LoggerFactory.getLogger("com.kmwllc.lucille.core.DocLogger");

Javadoc

All public Stages, Connectors, and Indexers have class-level Javadoc describing what the component does and listing its config parameters in a structured format:

/**
 * Copies values from a source field to a destination field based on the field mapping.
 *
 * <p>
 * Config Parameters -
 * <ul>
 *   <li>fieldMapping (Map&lt;String, Object&gt;) : A mapping of source field names to destination field names.</li>
 *   <li>updateMode (String, Optional) : Determines how writing will be handled. Defaults to 'overwrite'.</li>
 *   <li>isNested (Boolean, Optional) : Sets whether to treat field names as nested json paths. Defaults to false.</li>
 * </ul>
 */

This is a project convention: every Stage documents its config parameters in Javadoc using the Config Parameters - heading with a <ul> list. The format includes the parameter name, its type, whether it’s optional, and a description.

Interface methods have Javadoc. The Document, Publisher, Connector, and Messenger interfaces are thoroughly documented with @param, @return, and behavioral descriptions.

Private methods and implementation details generally do not have Javadoc, though complex private methods sometimes have inline comments explaining the approach.


Test Conventions

Test framework: JUnit 4. The project uses JUnit 4 (org.junit.Test, @Before, @After, etc.), not JUnit 5. This is a notable choice — most new Java projects use JUnit 5, but Lucille has remained on JUnit 4.

Test class naming: {ClassName}Test. Tests for CopyFields are in CopyFieldsTest. Tests for SolrIndexer are in SolrIndexerTest.

Test method naming: test{Behavior} in camelCase: testCopyFieldsReplace(), testDeleteFields(), testGetLegalProperties(). This follows the older JUnit 4 convention rather than the more descriptive should_behavior_when_condition style.

Test config files: Stored in src/test/resources/ in a subdirectory matching the test class name: src/test/resources/CopyFieldsTest/replace.conf. Loaded via StageFactory:

private StageFactory factory = StageFactory.of(CopyFields.class);
Stage stage = factory.get("CopyFieldsTest/config.conf");

Assertions: Use JUnit 4 static imports: assertEquals, assertFalse, assertNull, assertThrows. Assertion messages are used for complex assertions but often omitted for simple ones.

Mocking: Mockito is used for mocking external dependencies (HTTP clients, Kafka consumers, Solr clients). The core Lucille components are generally not mocked — test mode provides the full system running in-memory.


Deviations from Google Java Style

No enforced formatter. There is no checkstyle plugin or auto-formatter in the build. Formatting is maintained by convention and code review. This means minor inconsistencies exist (e.g., occasional extra blank lines, slightly varying import ordering).

Import ordering is not strictly enforced. Google style prescribes a specific import order (static imports first, then by package). Lucille generally groups imports logically but does not enforce a strict order. IDE auto-import ordering varies between contributors.

Wildcard imports appear occasionally. Google style discourages wildcard imports (import java.util.*). Lucille generally uses explicit imports but wildcards appear in some files (e.g., import java.util.* in Runner.java).

Line length is relaxed. Google style enforces 100 characters. Lucille allows longer lines, particularly for log messages, exception messages, and method signatures with many parameters.

JUnit 4 rather than JUnit 5. Not a style deviation per se, but notable for developers expecting modern JUnit conventions.

private static final logger named log rather than logger. Google style doesn’t prescribe logger naming, but many Java projects use logger. Lucille consistently uses log.

Field declarations sometimes lack explicit access modifiers in interfaces. Interface fields in Document are implicitly public static final without writing it out (e.g., String ID_FIELD = "id"). This is valid Java but some style guides prefer explicit modifiers.


Other Conventions

Config reading pattern: Required parameters use direct Typesafe Config getters. Optional parameters use ConfigUtils.getOrDefault:

this.sourceField = config.getString("sourceField");           // required
this.destField = ConfigUtils.getOrDefault(config, "destField", "output");  // optional with default

Exception handling in stages: Stages throw StageException for errors that should fail the current document. They do not catch and swallow exceptions silently. The Worker handles the exception and routes the document to a failure state.

Immutable fields where possible: Constructor-assigned fields are declared final. Mutable state is used only when necessary (e.g., counters, caches initialized in start()).

No Lombok. The project does not use Lombok or other annotation processors for boilerplate reduction. All getters, constructors, and builders are written explicitly.

Java 17 features: The project targets Java 17 and uses features like text blocks, var (sparingly), List.of(), Map.of(), and pattern matching in instanceof where appropriate.