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

Use JARs if you want strong CI/CD integration, test coverage, and debugger support. Use scripts if you want to iterate quickly, without a compile step.

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:

  1. 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.
  2. Annotate your functions with @TaxiFunction
  3. 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

ParameterTypeMeaningDefault
nameStringOptional explicit name of the function in Taxi.Defaults to the Kotlin/Java method name if not provided
descriptionStringOptional description - You can use Markdown within the string. Is converted into docs which are exposed via Orbital’s data catalog-
returnTypeStringAllows 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)

Under most scenarios, @TaxiParam is all you need. Other parameter types are documented here for completeness, but are only needed in advanced situations

Parameters

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:

ParameterTypeDefault
nameThe 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.
typeThe fully qualified Taxi Type name.Defaults to the corresponding Taxi primitive of the JVM type declared
nullableIndicates if it is acceptable for callers to pass nullIf 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 failure
  • Either<L,R> (Vavr, for Java) — right = success, left = failure
  • null → converted into a TypedNull

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

Throwing exceptions on the JVM comes with a performance penalty. Within Orbital's function runtime, all exceptions are converted to special nulls

To indicate that a function may fail, Orbital supports functions returning a special return type:

LanguageReturn type
JavaEither from vavr
KotlinResult

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:

META-INF/services/com.orbitalhq.functions.TaxiFunctionProvider
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:

pom.xml
<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

taxi.conf
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.

CustomFunctions.taxi.kts
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

taxi.conf
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.

FeatureJARsKotlin 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)
CompilationAt build time, as part of CI/CDAt runtime, on the Orbital serve
Support for 3rd party libraries
LoadingRequires configuring ServiceLoaderJust write the script, Orbital handles everything else
PackagingRequires seperate build and copy step - not handled by Orbital toolingNot 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 TypeTaxi Type
java.lang.BooleanBoolean
java.lang.StringString
java.lang.CharacterString
java.lang.IntegerInteger
kotlin.IntInteger
kotlin.ShortInteger
java.lang.ShortInteger
java.math.BigIntegerLong
kotlin.LongLong
java.lang.LongLong
java.math.BigDecimalDecimal
kotlin.DoubleDecimal
kotlin.FloatDecimal
java.lang.DoubleDecimal
java.lang.FloatDecimal
kotlin.DoubleDouble
kotlin.FloatDouble
java.lang.DoubleDouble
java.lang.FloatDouble
java.lang.ObjectAny
java.time.LocalDateLocalDate
kotlinx.datetime.LocalDateLocalDate
java.time.LocalTimeTime
kotlinx.datetime.LocalTimeTime
java.time.LocalDateTimeDateTime
kotlinx.datetime.LocalDateTimeDateTime
java.time.InstantInstant
kotlinx.datetime.InstantInstant

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) (via Result.failure<>(MyException("Boom")) )
  • A vavr Either (on Java) via Either.left()
Previous
Configuring Orbital
Next
Introduction to Semantic Integration