Maven Based Extensions

edit Introduced: 7.0

Maven Based Extensions

Lucee 7 introduces a new way to build extensions using Maven-based dependency management and classloading instead of OSGi bundles. This approach is simpler to build, easier to debug, and aligns with standard Java tooling.

Why Maven?

OSGi bundles have their own classloader, which creates isolation but also complexity — particularly around dependency resolution, Require-Bundle mismatches, and cold-start failures. The Maven approach uses standard Java classloading and lets Lucee resolve dependencies from Maven coordinates at runtime.

Key benefits:

  • Standard JARs — no OSGi manifest headers required
  • Maven-style dependency resolution — familiar tooling, no bundle wiring issues
  • Offline installation — JARs can be embedded directly inside the .lex file
  • Simpler builds — standard Maven compilation, no Felix runtime to contend with

Common OSGi pitfall: With OSGi bundles, Require-Bundle must use the Bundle-SymbolicName, NOT the Maven groupId:artifactId. These are often completely different strings. Mixing them up causes flaky resolution failures that only surface on cold starts — see LDEV-6189 for a real-world example. The Maven approach eliminates this class of bug entirely.

Lucee 7.1 OSGi improvement: If you do still need OSGi bundles, LDEV-6044 (7.1.0.21+) removes the static system packages list and lets Felix 7.x auto-detect packages from the JVM module system. This fixes long-standing issues where OSGi bundles couldn't import modern JDK packages like java.util.stream and java.time.

Extension Structure

Each Maven-based extension publishes two artifacts to Maven Central:

Artifact Purpose Example
{name} The JAR library org.lucee:mail
{name}-extension The .lex extension package org.lucee:mail-extension

Important: The artifactId must follow the {name}-extension pattern (e.g., crypto-extension, jsonata-extension, redis-extension). This is how Lucee discovers extensions when scanning a Maven GroupId (see Extension Provider). Note that GitHub repos use the reverse convention (extension-{name}), but the Maven artifactId must be {name}-extension to match the UUID mapping in ExtensionProvider.java.

Directory Layout

{name}-extension/
├── pom.xml                    # Root POM (packaging=pom)
├── build.xml                  # Ant build — handles .lex packaging
├── maven-install.sh           # Local build script
├── source/
│   ├── java/
│   │   ├── pom.xml            # Java POM (packaging=jar)
│   │   └── src/
│   │       └── org/lucee/extension/{name}/
│   ├── tld/                   # Tag Library Definitions (if needed)
│   ├── fld/                   # Function Library Definitions (if needed)
│   └── images/
│       └── logo.png
└── tests/

The root POM orchestrates the build. The source/java/pom.xml handles actual Java compilation. The build.xml assembles everything into the final .lex package.

Manifest Configuration

The extension manifest tells Lucee how to load the extension. Two key settings distinguish the Maven approach from legacy OSGi:

Manifest-Version: 1.0
version: "1.0.0.0"
id: "E0ACA85A-22DB-48FF-B2D6CD89D5D1709F"
name: "My Extension"
description: "..."
start-bundles: false
lucee-core-version: "7.0.0.110"

start-bundles: false

This tells Lucee not to load the extension's JARs as OSGi bundles. Without this, Lucee would attempt to install JARs into the Felix OSGi framework.

Class Definitions with Maven Coordinates

For extensions that register specific handlers (cache, JDBC, resources, etc.), use maven: in the manifest JSON:

cache: "[{'class':'org.lucee.extension.aws.dynamodb.DynamoDBCache','maven':'org.lucee:dynamodb:1.0.0.0'}]"
jdbc: "[{'class':'org.postgresql.Driver','maven':'org.postgresql:postgresql:42.7.1'}]"

The maven: attribute tells Lucee to use Maven-style classloading (via getRPCClassLoader()) to find the class, rather than looking for it in OSGi bundles.

Maven Based Cache Providers

OSGi-based cache extensions have always registered the cache class alongside the bundle that contains it, so Lucee can resolve the class later when a cache connection is created:

cache: "[{'class':'org.lucee.extension.myCache.MyCache','bundle-name':'my.cache.bundle','bundle-version':'1.0.0'}]"

Maven-based extensions work the same way — the manifest stores the class name and its Maven coordinates at install time:

cache: "[{'class':'org.lucee.extension.aws.dynamodb.DynamoDBCache','maven':'org.lucee:dynamodb:1.0.0.0'}]"

When a cache connection is later created using that class name — via the admin UI, application config, or programmatically — Lucee looks up the stored coordinates and loads the class via the Maven classloader.

Full support for all cache creation paths (including programmatic) landed in 7.1.0.93+, 7.0.4.21+, and 6.2.7.7+ (LDEV-6270).

See DynamoDB for a complete reference implementation of this pattern.

Maven Dependency Format (GAVSO)

Maven coordinates in Lucee use colon-separated Gradle-style notation:

groupId:artifactId:version

The full format with optional fields:

groupId:artifactId:version:scope:optional:checksum

Multiple dependencies are comma-separated:

maven: org.postgresql:postgresql:42.7.1,com.google.guava:guava:32.1.3-jre

Warning: The checksum field is the 6th colon-separated value. A checksum like sha256:abc123 would produce 7 parts and fail parsing. Use a plain hex digest without an algorithm prefix.

How Lucee Processes Maven Dependencies

  1. ExtensionMetadata reads the raw maven: string from the manifest
  2. MavenUtil.toGAVSOs() parses it into GAVSO objects (GroupId, ArtifactId, Version, Scope, Optional)
  3. At install time, Lucee resolves the JARs — first from the embedded /maven/ folder inside the .lex, then from Maven Central (or configured repositories)

Embedded Maven Repository

The recommended pattern bundles JARs inside the .lex in a Maven repository layout. This allows offline installation without requiring internet access at deploy time.

.lex Structure

my-extension.lex
├── META-INF/
│   ├── MANIFEST.MF
│   └── logo.png
├── tlds/
│   └── my-tags.tldx
└── maven/
    └── org/
        └── lucee/
            └── mylib/
                └── 1.0.0/
                    ├── mylib-1.0.0.jar
                    ├── mylib-1.0.0.pom
                    └── [transitive dependencies in repo layout]

When installing an extension, Lucee looks for /maven/ (or /mvn/) folders in the .lex and copies them to the Lucee maven directory ({lucee-server}/../mvn/), preserving the repository layout. The JARs are then loaded via standard Java classloading, not through Felix OSGi.

Building the Embedded Repo

The build.xml uses Maven's dependency:copy-dependencies goal with repository layout:

<!-- Copy dependencies with Maven repo layout -->
<exec dir="source/java" executable="mvn" failonerror="true">
    <arg value="-DoutputDirectory=${temp}/dependency"/>
    <arg value="-Dmdep.copyPom=true"/>
    <arg value="-Dmdep.useRepositoryLayout=true"/>
    <arg value="-DexcludeScope=provided"/>
    <arg value="dependency:copy-dependencies"/>
</exec>

The key flag is -Dmdep.useRepositoryLayout=true, which outputs dependencies in the groupId/artifactId/version/ structure that Lucee expects.

Embedded vs External Dependencies

Approach Pros Cons
Embedded /maven/ Offline install, self-contained Larger .lex file
External maven: only Smaller .lex, deduplication Requires internet at install time

The recommended pattern uses embedded dependencies — the JAR is bundled with the extension AND loaded via Maven classloading.

TLD and FLD with Maven

For extensions that provide custom tags (TLD) or Built-In Functions (FLD), the maven= attribute on class elements tells Lucee how to load the implementing class.

When migrating from OSGi, replace bundle-name/bundle-version with maven="{maven}"build.xml substitutes the real coordinates at package time.

TLD Example (Custom Tags)

<tag>
    <name>mail</name>
    <tag-class maven="{maven}">org.lucee.extension.mail.tag.Mail</tag-class>
    ...
</tag>

FLD Example (Built-In Functions)

<function>
    <name>myFunction</name>
    <class maven="{maven}">org.lucee.extension.mylib.functions.MyFunction</class>
    ...
</function>

Note: mvn= is also accepted as an alias for maven= in TLD/FLD elements. Add javax-tag-class with maven="{maven}" if dual javax/jakarta tag support is needed.

Version Requirements for TLD/FLD Maven Support

  • 7.1: TLD maven= on custom tags added in d5dfad16b — available from 7.1.0.2+
  • 7.0: FLD maven= for BIF functions added in 110d788f9 — available from 7.0.2.86+
  • 6.2: Not available — BIF/tag extensions must use OSGi bundles (see Lucee 6.2 Compatibility below)

The only reason a Maven extension needs Lucee 7.1 is when it defines custom tags with maven= on <tag-class>. That feature was added with Lucee 7.1. Manifest-only handlers and FLD-based BIF functions can target Lucee 7.0.

Build Flow

The typical build process for a Maven-based extension:

  1. Root pom.xml invoked with mvn clean install
  2. Maven Antrun plugin executes build.xml
  3. build.xml orchestrates:
    • Runs Maven in source/java/ to compile the JAR
    • Copies dependencies to target/extension/maven/ in repository layout
    • Generates the extension MANIFEST.MF
    • Packages everything into a .lex file (ZIP format)
  4. Maven deploy publishes both the JAR and .lex to Maven Central

For build file snippets, CI configuration, and pitfall details, see /docs/technical-specs/maven-extension-migration.yaml.

Version Compatibility

Feature Lucee 7.1 Lucee 7.0 Lucee 6.2
/maven/ folder extraction from .lex 7.0.0.68+ 6.2.0.300+
Manifest cache:/jdbc: with maven: 7.0.0.68+ 6.2.0.285+
Maven cache provider (all creation paths) 7.1.0.93+ 7.0.4.21+ 6.2.7.7+
start-bundles: false
GAVSO coordinate parsing
TLD maven= (custom tags) 7.1.0.2+
FLD maven= (BIF functions) 7.1.0.2+ 7.0.2.86+

Minimum lucee-core-version

The lucee-core-version in MANIFEST.MF must reflect what your extension actually uses — not a blanket 7.1 for every Maven extension.

Extension type Typical minimum Example
Manifest handlers only (resource, cache, JDBC) 7.0.0.68+ S3 declares 7.0.0.211-BETA
BIF functions (FLD with maven=) 7.0.2.86+ Most function-only extensions
Custom tags (TLD with maven=) 7.1.0.2+ Rare — Image defines <cfimage>

What This Means in Practice

Extension type 7.0.2.86+ / 7.1.0.2+ 7.0.0.68–7.0.2.85 6.2.0.285+
Cache handler (manifest cache:) ✅ Maven ✅ Maven ✅ Maven
JDBC driver (manifest jdbc:) ✅ Maven ✅ Maven ✅ Maven
Resource provider (manifest resource:) ✅ Maven ✅ Maven ✅ Maven
BIF functions (FLD) ✅ Maven ❌ OSGi only ❌ OSGi only
Custom tags (TLD) ✅ Maven (7.1 only) ❌ OSGi only ❌ OSGi only

Lucee 6.2 Compatibility

For extensions that provide BIF functions or custom tags on Lucee 6.2, the Maven pattern cannot be used for TLD/FLD class resolution. Instead, you need a shaded OSGi bundle with all dependencies included.

Why shading is required:

  • OSGi bundles have their own classloader
  • The OSGi classloader cannot see classes in the /maven/ folder (different classloader)
  • Third-party dependencies must be shaded into the OSGi bundle JAR

The approach:

  • Use the Maven shade plugin to include dependencies in your extension JAR
  • Build as an OSGi bundle with Bundle-SymbolicName, Bundle-Version, etc.
  • Place the shaded JAR in the /jars/ folder
  • Reference the bundle name/version in FLD/TLD files

TL;DR: If you need 6.2 support for BIF/tag extensions, stick with shaded OSGi bundles. For 7.0.2.86+ (FLD) or 7.1.0.2+ (TLD), use the Maven pattern.

Legacy: Bundled JARs

Older extensions use /jars/, /jar/, /bundles/, /bundle/, /lib/, or /libs/ folders inside the .lex — all six are equivalent. Lucee examines each JAR to determine its type:

JAR Type Destination Loaded Via
OSGi bundle (has Bundle-SymbolicName) {lucee-server}/bundles/ Felix OSGi framework
Plain JAR {lucee-server}/lib/ Standard classloader

The folder name in your extension is purely organisational — Lucee auto-detects based on the JAR's manifest headers.

Migrating from OSGi

This section guides maintainers through converting a legacy OSGi Lucee extension to the Maven-based model. The worked example is the Image extension (3.0.x OSGi → 3.1.x Maven).

When to Migrate

Migrate when the extension targets Lucee 7+, you want standard Maven dependency management, and you are ready to drop OSGi bundle wiring.

Keep an OSGi line when you must support Lucee 6.2 for BIF/tag extensions, or need a stable bugfix branch for older installs.

A common strategy: bump the major/minor version line for the Maven branch (e.g. Image 3.0.x for 6.2, 3.1.x for 7+).

What Changes

  1. Build — Maven compiles the JAR; Ant assembles the .lex
  2. Manifeststart-bundles: false; keep the extension UUID unchanged
  3. FLD/TLD — replace bundle-name/bundle-version with maven="{maven}"; build.xml substitutes coordinates at package time
  4. Dependencies — move from libs/ and manual copies into source/java/pom.xml
  5. Artifacts — publish a full .lex (with embedded maven/ repo) and optionally a lite extension (.lite.lex, Maven classifier lite)

Migration Checklist

1. Build system

  • [ ] Add root pom.xml and source/java/pom.xml
  • [ ] Wire build.xml via maven-antrun-plugin
  • [ ] Ensure maven-build depends on init (wipes target/ each build)
  • [ ] Copy dependencies with Maven repository layout; run copy-parent-poms.sh
  • [ ] Package full .lex and lite extension .lite.lex

2. Extension metadata

  • [ ] Set start-bundles: false
  • [ ] Set lucee-core-version per table above
  • [ ] Keep extension id (UUID) unchanged

3. FLD / TLD (if applicable)

  • [ ] Switch to maven="{maven}" on all <class> and <tag-class> entries
  • [ ] Add javax-tag-class if dual javax/jakarta tag support is needed

4. Dependencies

  • [ ] Move JARs into pom.xml; provided scope for org.lucee:lucee
  • [ ] Remove system scope, manual build.xml JAR copies, and legacy org.lucee repackages

5. Java source

  • [ ] Remove OSGi bundle manifest from extension JAR
  • [ ] Replace OSGi-specific version lookups

6. CI and testing

  • [ ] mvn clean install -Dgoal=install
  • [ ] Test with lucee/script-runner, extensionDir: target/
  • [ ] Upload target/*.lex as artifacts

Details for each step: maven-extension-migration.yaml.

Full vs Lite Extension

Most Maven extensions publish two install packages:

Package File Contents
Full {name}-extension-{version}.lex Metadata + embedded maven/** JARs
Lite extension {name}-extension-{version}.lite.lex Metadata only (no maven/**)

The lite extension is for Lucee Light/Zero and Docker setups where dependencies resolve from Maven Central at install time. The full .lex is for offline/self-contained installs and is what CI typically tests against.

Maven coordinates for the lite extension use classifier lite:

org.lucee:image-extension:3.1.0.9-BETA:lex        # full
org.lucee:image-extension:3.1.0.9-BETA:lite:lex   # lite extension

See Extension Installation for install methods.

Testing

mvn -B -e -f pom.xml clean install -Dgoal=install
ls -lh target/*.lex

Run extension tests via script-runner with extensionDir pointing at target/. Label test components (e.g. labels="image") and pass testAdditional for extension-local tests.

If tests use _internalRequest for .cfm fixtures, paths must use contractPath() — not raw filesystem paths. See the tech spec test_patterns.internal_request section.

Common Issues

Problem Quick fix
Old JARs still in .lex after dependency removal maven-build must depend on init; delete target/ locally
Lite extension fails at runtime offline Use full .lex, or pre-populate {lucee-server}/../mvn/
Tags fail on 7.0.x Extension defines TLD tags — requires 7.1.0.2+
MissingIncludeException in CI tests Use contractPath() web paths in _internalRequest

Full pitfall reference: tech spec pitfalls section.

Reference Extensions

These extensions demonstrate the Maven pattern and can be used as templates:

Extension Use as template for
extension-mail Simple Maven migration with TLD
extension-s3 Manifest-only handler, lite extension, 7.0 baseline
extension-image FLD + TLD (custom tags), dependency cleanup, 7.1 minimum
extension-dynamodb Cache handler, no FLD/TLD
extension-ftp Resource provider
extension-debugger Debugging extension

See also