Extending Orbital
Custom Functions
Orbital lets you extend the platform with custom functions, written in Kotlin or Java.
These functions behave just like built-in functions: you can call them from Taxi queries, use them in transformations, and reuse them across projects.
Why custom functions?
While Taxi’s standard library covers common operations, sometimes you need:
- Custom domain-specific calculations
- Utility functions for reuse across queries
Orbital supports two ways of writing custom functions:
- Precompiled JARs (Java or Kotlin)
- Kotlin scripts (
.taxi.kts
files)
Choosing between JAR files vs Kotlin scripts
Writing custom functions
The process for writing a function is the same, regardless if you’re using Kotlin or Java, or which method you choose for pacakging your functions.
The steps are as follows:
- Implement the
TaxiFunctionProvider
interface (com.orbitalhq.functions.TaxiFunctionProvider
)TaxiFunctionProvider
is a marker interface, with no methods, but is used to find the classes that have custom functions- If you’re using Kotlin Scrpting the you can skip this - the scripting engine handles this for you.
- Annotate your functions with
@TaxiFunction
- Annotate parameters with
@TaxiParam
Here’s an example:
// Always put your functions in a package - this will
// dictate the namespace that the corresponding Taxi function is declared under
package com.orbitalhq.example
import com.orbitalhq.functions.TaxiFunction
import com.orbitalhq.functions.TaxiFunctionProvider
import com.orbitalhq.functions.TaxiParam
class ExampleFunctions : TaxiFunctionProvider { // <-- implement TaxiFunctionProvider
@TaxiFunction // <-- annotation
fun sayHello(@TaxiParam name: String):String {
return "Hello, $name"
}
}
The function declarations are fairly vanilla. Orbital handles the marshalling into and out of your function for you, as well as generating the corresponding Taxi function definitions for your functions.
TaxiFunction
Functions must be annotated with @TaxiFunction
(don’t forget to import com.orbitalhq.functions.TaxiFunction
)
The following parameters are available on the annotation
Parameter | Type | Meaning | Default |
---|---|---|---|
name | String | Optional explicit name of the function in Taxi. | Defaults to the Kotlin/Java method name if not provided |
description | String | Optional description - You can use Markdown within the string. Is converted into docs which are exposed via Orbital’s data catalog | - |
returnType | String | Allows customizing the return type. Provide the fully qualified name of the Taxi type this function returns. If your function returns either a TypedInstance , or a non-scalar type, this must be defined | The Taxi Primitive type that corresponds to the declared Kotlin / Java return type of your function |
Input binding
Custom functions can access the declared parameters, along with various parts of the Orbital execution context, simply by declaring them in the function signature.
TaxiParam is all you need (probably)
@TaxiParam
is all you need. Other parameter types are documented here for completeness, but are only needed in advanced situationsParameters
Your function can accept as many input parameters as required. The TaxiQL engine will provide these as passed by callers. Marshalling to/from Taxi instances is handled by Orbital, allowing you to work directly with the native JVM types.
Parameters must be annotation with @TaxiParam
, which accepts the following parameters:
Parameter | Type | Default |
---|---|---|
name | The name of the parameter as published in the generated Taxi function. | Defaults to the name of the method parameter if avaialble, otherwise p0 , p1 , etc. |
type | The fully qualified Taxi Type name. | Defaults to the corresponding Taxi primitive of the JVM type declared |
nullable | Indicates if it is acceptable for callers to pass null | If not provided, and using Kotlin, then will match the nullability of the Kotlin parameter. Otherwise, defaults to false . |
For example:
import com.orbitalhq.functions.TaxiParam
@TaxiFunction
String sayHello(
@TaxiParam String name,
// suffix will be defined of type con.foo.NameSuffix
// and will be marked as nullable
@TaxiParam(type = "com.foo.NameSuffix", nullable = true) String suffix
) {
return "Hello " + name
}
import com.orbitalhq.functions.TaxiParam
@TaxiFunction
fun sayHello(
@TaxiParam name: String,
// suffix will be defined of type con.foo.NameSuffix
// and will be marked as nullable, to match the Kotlin definition
@TaxiParam(type = "com.foo.NameSuffix") suffix: String?
):String {
return "Hello, $name"
}
Schema
Callers can request the Schema be injected, by declaring an input parameter of type Schema
(com.orbitalhq.schemas.Schema
).
This provides access to the full compiled Taxi schema, which allows for introspection, and is required when manually constructing TypedInstances (though, you normally don’t need to do this.)
import com.orbitalhq.functions.TaxiParam
import com.orbitalhq.schemas.Schema
@TaxiFunction
String sayHello(
@TaxiParam String name,
Schema schema,
) {
// ...
}
import com.orbitalhq.functions.TaxiParam
import com.orbitalhq.schemas.Schema
@TaxiFunction
fun sayHello(
@TaxiParam name: String,
schema: Schema
):String {
// ...
}
EvaluationValueSupplier
The EvaluationValueSupplier provides access to the current context under which functions are executing, and provides ability to do things like:
- Look up values in scope by type
- Look up values in scope by their declared name
- Evaluate functions or Taxi queries (including queries that load other data)
- Construct Taxi objects
This is a powerful - but advanced - supplier. It’s unlikely to be needed for most use-cases
import com.orbitalhq.functions.TaxiParam
import com.orbitalhq.models.EvaluationValueSupplier
@TaxiFunction
String sayHello(
@TaxiParam String name,
EvaluationValueSupplier valueSupplier,
) {
// ...
}
import com.orbitalhq.functions.TaxiParam
import com.orbitalhq.models.EvaluationValueSupplier
@TaxiFunction
fun sayHello(
@TaxiParam name: String,
valueSupplier: EvaluationValueSupplier
):String {
// ...
}
Returning values
In it’s simplest form, you can just return a Scalar type from your function, and Orbital will handle everything for you.
Values can be returning in the following ways:
- Plain values (e.g.
String
,Int
) TypedInstance
(if you want full control of wrapping values)Result<T>
(Kotlin) — success or failureEither<L,R>
(Vavr, for Java) — right = success, left = failurenull
→ converted into aTypedNull
JVM scalar types are marshalled to/from Taxi TypedInstances (the internal way that values are stored at runtime by the Taxi engine) as described in Type Marshalling
import com.orbitalhq.functions.TaxiParam
import com.orbitalhq.models.EvaluationValueSupplier
@TaxiFunction
// Orbital will automatically handle conversion between the JVM String, and a Taxi TypedInstance
String sayHello(
@TaxiParam String name,
) {
// ...
}
import com.orbitalhq.functions.TaxiParam
import com.orbitalhq.models.EvaluationValueSupplier
@TaxiFunction
fun sayHello(
@TaxiParam name: String,
valueSupplier: EvaluationValueSupplier
// Orbital will automatically handle conversion between the JVM String, and a Taxi TypedInstance
):String {
// ...
}
Returning failures
Sometimes functions can fail, and you want to indicate that to your callers.
Custom functions can model failures using special return types - Either<>
(Java) or Result<>
(Kotlin)
Avoid throwing exceptions
To indicate that a function may fail, Orbital supports functions returning a special return type:
Language | Return type |
---|---|
Java | Either from vavr |
Kotlin | Result |
When a function returns a failure, the result is mapped to a TypedNull
- a special kind of TypedInstance
, which carries details of what the failure was.
public class SafeDivideFunctions implements TaxiFunctionProvider {
@TaxiFunction(description = "Safely divide two integers. Returns Right(result) or Left(error message).")
public Either<String, Integer> safeDivide(
@TaxiParam(name = "numerator") int numerator,
@TaxiParam(name = "denominator") int denominator) {
if (denominator == 0) {
// Left = failure
return Either.left("Cannot divide by zero");
} else {
// Right = success
return Either.right(numerator / denominator);
}
}
}
class SafeDivideFunctions : TaxiFunctionProvider {
@TaxiFunction(description = "Safely divide two integers. Returns Result.success or Result.failure.")
fun safeDivide(
@TaxiParam(name = "numerator") numerator: Int,
@TaxiParam(name = "denominator") denominator: Int
): Result<Int> {
return if (denominator == 0) {
Result.failure(IllegalArgumentException("Cannot divide by zero"))
} else {
Result.success(numerator / denominator)
}
}
}
Providing functions via a JAR file
You can package your functions as a JAR file - authored in either Kotlin or Java - which is copied to your Taxi project.
Dependencies
Add the following Maven dependencies:
<dependencies>
<dependency>
<groupId>com.orbitalhq</groupId>
<artifactId>functions-api</artifactId>
<version>${orbital.version}</version>
</dependency>
</dependencies>
You’ll also need to declare the Orbital maven repository:
<repositories>
<repository>
<id>orbital-releases</id>
<url>https://repo.orbitalhq.com/release</url>
</repository>
<!-- optional - for snapshots -->
<repository>
<id>orbital-snapshots</id>
<url>https://repo.orbitalhq.com/snapshot</url>
<snapshots>
<enabled>true</enabled>
</snapshots>
</repository>
</repositories>
Writing functions
Create a class that implements TaxiFunctionProvider
, and annotate methods with @TaxiFunction
.
Function parameters must be annotated with @TaxiParam
.
package com.acme.functions
class GreetingFunctions : TaxiFunctionProvider {
@TaxiFunction(description = "Greets a user by name")
fun greet(@TaxiParam(name = "name") name: String): String =
"Hello, $name"
}
The function can then be used in a query:
find {
greeting : String = greet("Jimmy")
}
// returns:
{
greeting : "Hello, Jimmy"
}
Service Loader
Orbital uses a Java Service Loader to discover the actual functions at runtime, which must be configured.
Manually
To manually declare the services, create a file at src/main/resources/META-INF/services/com.orbitalhq.functions.TaxiFunctionProvider
, with a list of the
names of your provider classes:
com.acme.functions.GreetingFunctions
Using Google's Auto Service
Alternatively, you can use Google’s Auto Service to handle the boilerplate for you.
Add the following Maven dependencies:
<dependency>
<groupId>com.google.auto.service</groupId>
<artifactId>auto-service-annotations</artifactId>
<version>1.1.1</version>
<scope>provided</scope>
</dependency>
And wire it into your maven build (note the different configurations, depending on if your code is in Kotlin or Java)
<plugins>
<plugin>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-plugin</artifactId>
<executions>
<execution>
<id>kapt</id>
<goals>
<goal>kapt</goal>
</goals>
<configuration>
<annotationProcessorPaths>
<annotationProcessorPath>
<groupId>com.google.auto.service</groupId>
<artifactId>auto-service</artifactId>
<version>1.1.1</version>
</annotationProcessorPath>
</annotationProcessorPaths>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
<plugins>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>com.google.auto.service</groupId>
<artifactId>auto-service</artifactId>
<version>${auto-service.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
Then, in your function class, add the @AutoService
annotation:
import com.google.auto.service.AutoService;
@AutoService(TaxiFunctionProvider.class)
class ExampleFunctions : TaxiFunctionProvider {
// ...snip...
}
import com.google.auto.service.AutoService
@AutoService(TaxiFunctionProvider::class)
public class ExampleFunctions implements TaxiFunctionProvider {
// ...snip...
}
When Orbital loads your project, it will discover the JAR, register your functions into the schema, and make them available to call from Taxi.
Taxi project configuration
The JAR files must be added to your Taxi project, and the location defined in your taxi.conf
’s additionalSources
section, using the key @orbital/function-jar
name: orbital/function-jar-example
version: 0.3.0
sourceRoot: src/
additionalSources: {
"@orbital/function-jar" : "functions/*.jar"
}
Writing functions in Kotlin scripts
Kotlin Scripting provides a lightweight alternative to compiling functions into JAR’s. Scripts authored in plain Kotlin, and avoids the build steps required with JARs.
- ✅ Fast iteration, no compilation step
- ✅ Easier for quick prototyping
- ✅ Avoids having to deal with ServiceLoader
- ✅ Avoids having to deal with ServiceLoader
- ✅ Testable via Preflight
- ❌ No IDE debugger support
- ❌ Limited CI/CD support outside of Preflight
- ❌ Unable to use 3rd party libraries
- ❌ Compilation happens at runtime
Kotlin scripting functions are compiled at runtime within Orbital, whenever the schema changes. However, there’s minimal performance difference in terms of actual function execution.
Writing a kotlin script
For quick iteration, you can define functions inline in .taxi.kts
scripts.
Scripts are compiled dynamically at runtime.
import com.orbitalhq.functions.*
@TaxiFunction
fun greet(@TaxiParam name: String): String = "Hello, $name"
When authoring a Kotlin Taxi script, you do not declare a class - simply write the functions directly in the script file.
The @TaxiFunction
, @TaxiParam
annotations are automatically added to the classpath at runtime.
Scripts must use the .taxi.kts
extension.
Orbital compiles them into providers automatically.
Taxi config for scripts
Kotlin script files should be added to your Taxi project, and the location defined in your taxi.conf
’s additionalSources
section, using the key @orbital/function-kts
name: orbital/function-jar-example
version: 0.3.0
sourceRoot: src/
additionalSources: {
"@orbital/function-kts" : "functions/*.taxi.kts"
}
Enabling completion support in IntelliJ settings
IntelliJ provides completion support for Kotlin scripting, including Taxi Kotlin scripts.
First, fetch the DSL JAR from the Orbital repository:
If working against a released version:
mvn dependency:get -Dartifact=com.orbitalhq:functions-kotlin-scripting-api:0.36.0 -DremoteRepositories=https://repo.orbitalhq.com/release
If working against a snapshot version:
mvn dependency:get -Dartifact=com.orbitalhq:functions-kotlin-scripting-api:0.36.0-SNAPSHOT -DremoteRepositories=https://repo.orbitalhq.com/snapshot
Then, inside IntelliJ:
- Go to Preferences -> Build, Execution, Deployment -> Compiler -> Kotlin Compiler
- At the bottom you will find a section Kotlin Scripting
- Complete the field:
Script definition template classes to load explicitly
:com.orbitalhq.functions.kotlin.KotlinTaxiFunctionScript
- Complete the field:
Classpath required for loading script definition template classes
:<PATH_TO_YOUR_M2_REPO>/.m2/repository/com/orbitalhq/functions-kotlin-scripting-api/0.36.0/functions-kotlin-scripting-api.jar
Then:
- Go to Preferences -> Language & Frameworks -> Kotlin -> Kotlin Scripting
- Make sure the script template Taxi Custom Functions is active and above the default Kotlin Script
- Apply changes
Choosing between JARs and Scripts
There are a number of consdierations when choosing between JARs and Kotlin Scripts - though the choice ultimately depends on the preference of the team.
Feature | JARs | Kotlin scripts |
---|---|---|
Debuggable | ✅ Via IDE when authoring (not available at runtime in Orbital) | ❌ IDE’s have limited support for debugging Kotlin Scripts, no runtime debugging in Orbital |
Unit-testable | ✅ Standard JVM testing approaches | ⚠️ (Preflight only) |
Compilation | At build time, as part of CI/CD | At runtime, on the Orbital serve |
Support for 3rd party libraries | ✅ | ❌ |
Loading | Requires configuring ServiceLoader | Just write the script, Orbital handles everything else |
Packaging | Requires seperate build and copy step - not handled by Orbital tooling | Not required - script file is deployed as-is |
Appendix
Namespaces
Always place your custom functions inside a namespace to avoid collisions:
namespace com.acme.functions
declare function greet(name: String): String
Orbital infers the namespace from the package name (for JARs) or script path (for .taxi.kts
).
Explicit namespaces keep your functions organized and predictable.
Testing
Custom functions are available for use in Preflight tests, so can be used as part of your project’s testing strategy.
Additionally, if you’re bundling your functions as a JAR file, you can make full use of CI/CD and standard JVM test frameworks
TypedInstance
TypedInstance
is the main way that a Taxi value is held in memory in the JVM.
It’s a combination of the following:
- The actual value
- The Taxi type
- It’s DataSource (which powers Lineage)
Inputs and Outputs of Functions are always a TypedInstance. However, Orbital will handle marshalling between the Taxi TypedInstance and the JVM value for you.
You generally don’t need to interact with a TypedInstance, unless you’re trying to do some advanced things
Type Marshalling
Values are automatically marshalled between their Taxi and JVM counterparts
JVM Type | Taxi Type |
---|---|
java.lang.Boolean | Boolean |
java.lang.String | String |
java.lang.Character | String |
java.lang.Integer | Integer |
kotlin.Int | Integer |
kotlin.Short | Integer |
java.lang.Short | Integer |
java.math.BigInteger | Long |
kotlin.Long | Long |
java.lang.Long | Long |
java.math.BigDecimal | Decimal |
kotlin.Double | Decimal |
kotlin.Float | Decimal |
java.lang.Double | Decimal |
java.lang.Float | Decimal |
kotlin.Double | Double |
kotlin.Float | Double |
java.lang.Double | Double |
java.lang.Float | Double |
java.lang.Object | Any |
java.time.LocalDate | LocalDate |
kotlinx.datetime.LocalDate | LocalDate |
java.time.LocalTime | Time |
kotlinx.datetime.LocalTime | Time |
java.time.LocalDateTime | DateTime |
kotlinx.datetime.LocalDateTime | DateTime |
java.time.Instant | Instant |
kotlinx.datetime.Instant | Instant |
Best practices and considerations
Consider if you can use an existing function instead
Taxi has a really rich StdLib of common functions, and strong support for composing existing functions together to form new functions.
You really only need to declare a custom function if you require capabilities not currently exposed via the StdLib, or if you wish to encapsulate complex business logic.
Performance
Unlike most things in Orbital, function evaluation is blocking - it blocks the current thread that the function evaluation is occurring on.
Therefore, you must make sure you keep functions fast. While it is technically possible to execute sub-queries within a function, you should avoid doing so, as it risks slowing down the entire Orbital server while waiting for a response.
However, fetching of arguments to pass into a function is non-blocking. Therefore, rather than running a query to fetch data from within your function, define an argument for the data you need as an input to your function.
Avoid throwing an exception
Throwing exceptions has a performance impact on the JVM, and should be avoided.
Orbital catches all exceptions thrown from a custom function, and marshals it back to a TypedNull
, with data source of FailedEvaluatedExpression
,
which contains details of the failure.
Rather than throwing an exception, return one of the following types to signal failure:
- A
Result<>
(on Kotlin) (viaResult.failure<>(MyException("Boom"))
) - A vavr Either (on Java) via
Either.left()