> ## Documentation Index
> Fetch the complete documentation index at: https://docs.alginte.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Custom functions

> Extend the expression language with your own Java: write a public static method, register it under kafka.streams.transformation.functions, put the jar on the console's classpath, and call it as #name(args) in any expression.

Expressions are sandboxed: `T(...)` resolves only a short allowlist of safe types and `new` is
blocked ([Calling Java](/streams/spel#calling-java)). Your own code enters through one door, a
**registered function**: a public static Java method the console loads at startup and every
expression can call as `#name(args)`. This page is the worked example, from the class to the
container, with what happens when a step is wrong.

<Note>
  Everything here was run against the published `alginte/alginte:0.11.0` image, and the two
  behaviours a function can have, a value and an exception, are rows in the fidelity corpus that
  asserts the editor preview, the validator and the deployed runtime agree.
</Note>

## 1. Write the class

No dependency on Alginte, no annotation, no interface. A class with a public static method:

```java theme={null}
package com.acme.util;

public final class Masks {
    private Masks() {}

    public static String maskEmail(String email) {
        if (email == null) return null;
        int at = email.indexOf('@');
        if (at <= 1) return email;
        return email.charAt(0) + "***" + email.substring(at);
    }
}
```

Compile it for the console's Java and pack it:

```bash theme={null}
javac --release 25 -d classes src/com/acme/util/Masks.java
jar --create --file masks.jar -C classes .
```

Three rules the registry enforces at startup, each with its own message (see
[When it cannot resolve](#when-it-cannot-resolve)):

* The method is **public static**. Instance methods are not callable.
* The name is **unambiguous** in its class: no overloads. Two `maskEmail` methods with different
  parameter lists abort startup, even if only one would ever match.
* Arguments arrive as what the expression evaluates to (a `String` from `value.get('email')` on a
  JSON Schema or Avro record, boxed numbers, maps, lists), and the return value is what the
  expression continues with. Kafka Streams calls the function on a stream thread, once per record,
  so it must be thread-safe and should not block.

## 2. Register it

One property per function, the name you will call it by on the left and `class#method` on the
right:

```properties theme={null}
kafka.streams.transformation.functions.maskEmail=com.acme.util.Masks#maskEmail
```

The function name is a map key, which decides how you pass it. A JVM system property keeps its
case:

```bash theme={null}
JAVA_OPTS="-Dkafka.streams.transformation.functions.maskEmail=com.acme.util.Masks#maskEmail"
```

An environment variable does not: Spring lower-cases map keys bound from the environment, so
`KAFKA_STREAMS_TRANSFORMATION_FUNCTIONS_MASKEMAIL` registers `#maskemail`. Either name works;
use the one you registered. A mounted properties file passed with
`spring.config.additional-location` keeps the case too ([Configuration](/operate/configuration)).

## 3. Put the jar on the classpath

The Docker image runs Spring Boot's launcher over the exploded application in `/app`, and the
launcher lists `/app/BOOT-INF/lib/` at start. So a jar **file** mounted there is on the classpath
with nothing else changed:

```bash theme={null}
docker run -p 8888:8888 \
  -e SPRING_KAFKA_BOOTSTRAPSERVERS=<kafka-host>:9092 \
  -v /path/to/masks.jar:/app/BOOT-INF/lib/masks.jar:ro \
  -e JAVA_OPTS="-Dkafka.streams.transformation.functions.maskEmail=com.acme.util.Masks#maskEmail" \
  alginte/alginte:latest
```

Mount the file, not a directory: `/app` is owned by root and the launcher does not descend into
subdirectories of `lib/`. For a **directory** of jars, switch to the `PropertiesLauncher` and
point `loader.path` at the mount; the application's own classes and libraries stay on the
classpath:

```bash theme={null}
docker run -p 8888:8888 \
  -e SPRING_KAFKA_BOOTSTRAPSERVERS=<kafka-host>:9092 \
  -v /path/to/jars:/extra:ro \
  -e JAVA_OPTS="-Dkafka.streams.transformation.functions.maskEmail=com.acme.util.Masks#maskEmail" \
  --entrypoint /bin/sh alginte/alginte:latest \
  -c 'exec java $JAVA_OPTS -cp /app -Dloader.path=/extra org.springframework.boot.loader.launch.PropertiesLauncher'
```

<Warning>
  `-Dloader.path` under the image's default entrypoint does nothing: the default launcher ignores
  it, and startup aborts with `class not found on the classpath`, the same message a missing
  mount produces. Read that message as "the jar is not on the classpath", not as a typo in the
  class name.
</Warning>

The console starts only when every registered function resolved, so a healthy `/actuator/health`
after startup is the proof the jar was found.

## 4. Call it

In any expression, on any operator:

```text theme={null}
#maskEmail(value.get('email'))
```

The sample preview evaluates it against the real record like any other expression, so
`john.doe@example.com` on the `in` line reads `j***@example.com` on the `out` line before anything
is deployed. The validator knows the name: a call to a function that is not registered is
reported while you type, as an unknown function. Completion offers the generic `#fn()` snippet
rather than your function's name.

A function that throws fails the record the same way everywhere: the preview reports the
exception on the step, the validator marks the expression, and the deployed stream fails the
record's transformation with the same exception, as it does for any expression error. There is
no path on which the throw is swallowed.

## When it cannot resolve

Resolution is fail-fast: the console does not start, and the message names the function and the
reason. The four cases:

| Message                                                               | Cause                                                                                                                           |
| --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `reference must be 'fully.qualified.ClassName#staticMethod', got: …`  | No `#`, or nothing on one side of it.                                                                                           |
| `class not found on the classpath: com.acme.util.Masks`               | The jar is not on the classpath: not mounted, mounted as a directory under `lib/`, or `loader.path` under the default launcher. |
| `no public static method 'maskEmail' on com.acme.util.Masks`          | The method is missing, not static, or not public.                                                                               |
| `method 'maskEmail' on com.acme.util.Masks is overloaded (2 matches)` | Two public static methods share the name; rename one.                                                                           |

## What the sandbox does and does not cover

The expression sandbox restricts what an expression can reach: the `T(...)` allowlist and the
blocked `new` operator keep a one-line expression from calling into the process. A registered
function is outside that boundary by design. It is your Java, running in the console's process
with the console's rights, and nothing inspects what it does. Register only code you would run
in the console anyway, and keep the trusted network boundary the console assumes
([Production](/operate/production#the-security-boundary)): whoever can author expressions can
call every registered function with any argument.
