Skip to main content
Version: Next

StructDefs and Structs

A StructDef is a user-definable schema for structured objects in LittleHorse. StructDefs supersede the capabilities of JSON_OBJ, ensuring that your structured objects match a specific schema before they enter your workflows.

Concepts

A Struct is a complex data type represented as a map of fields and values, while a StructDef is a metadata object that defines the structure and types of a Struct. These concepts allow for strong typing in workflows, replacing the less structured JSON_OBJ and JSON_ARR types over time.

  • A StructDef serves as a blueprint for the Struct, specifying the types and constraints of its fields.
  • A Struct instance that holds the actual data at runtime, similar to a JSON_OBJ.

Before you can use a Struct, you must first define a StructDef.

The StructDef

In LittleHorse, a StructDef is a Metadata Object defining the blueprint for an object's schema.

StructDefs define a list of fields that a matching Struct must include. Each StructDef field has a TypeDefinition, may optionally include a default value, and can be marked as nullable. StructDefs in LittleHorse are similar to a POJO in Java, a Struct in Go, and a Dataclass in Python.

The Struct

A Struct instance is a type of VariableValue that conforms to a specific StructDef.

When a Struct is passed into the server, it will be compared to the StructDef defined at that value's entrypoint. For example, a Struct passed as an input variable to a WfRun will be compared to the corresponding VariableDef's TypeDefinition.

During this validation process, the server will ensure that each field defined in the StructDef exists in the Struct and that every field's value matches the corresponding TypeDefinition for that field.

You can pass a Struct anywhere you can traditionally pass VariableValues, such as an input variable on a WfRun, an argument to a TaskRun, and the content of an ExternalEvent.

In Practice

To use a Struct in LittleHorse, you need to do the following:

  1. Define and register the StructDef
  2. Use the StructDef in a WfSpec, TaskDef, or ExternalEventDef.
  3. Pass a matching Struct into the corresponding WfRun, TaskRun, or ExternalEvent.

In the following example, we will create StructDef defining a Car. Then, we will use that StructDef as an input variable to a WfSpec.

Define the StructDef

Let's define a StructDef representing a Car.

To define a StructDef in Java, we can define a Java class with the @LHStructDef annotation. Any field with matching Getters and Setters will be serialized into your StructDef schema.

package io.littlehorse.examples;

import io.littlehorse.sdk.worker.LHStructDef;

@LHStructDef("car")
public class Car {
private String make;
private String model;
private int year;

public Car(String make, String model, int year) {
this.make = make;
this.model = model;
this.year = year;
}

public Car() {}

public String getMake() {
return this.make;
}

public void setMake(String make) {
this.make = make;
}

public String getModel() {
return this.model;
}

public void setModel(String model) {
this.model = model;
}

public int getYear() {
return this.year;
}

public void setYear(int year) {
this.year = year;
}
}

To register your StructDef in Java, run the following code:

package io.littlehorse.examples;

import io.littlehorse.sdk.common.config.LHConfig;
import io.littlehorse.sdk.wfsdk.internal.structdefutil.LHStructDefType;

public class Main {
public static void main(String[] args) {
LHConfig config = new LHConfig();

// LHStructDefType wraps your class with special logic to validate it and convert it to a StructDef
LHStructDefType lhStructDefType = new LHStructDefType(Car.class);

// Calls `RPC PutStructDef` with your StructDef signature
config.getBlockingStub().putStructDef(lhStructDefType.toPutStructDefRequest());
}
}

Use the StructDef in a TaskDef

Now that our StructDef is registered, we can use it in a TaskDef as a parameter for our task.

tip

If you aren't familiar with TaskDefs and how to register them, read the Workflows concepts page before continuing.

Using a StructDef as a parameter for your task is simple. Just reference the StructDef class in your Task Signature as you would with any other type.

package io.littlehorse.examples;

import io.littlehorse.sdk.worker.LHTaskMethod;

class CarTaskWorker {
@LHTaskMethod("describe-car")
public String describeCar(Car car) {
return "You drive a " + car.getBrand() + " " + car.getModel();
}
}

Use the StructDef in a WfSpec

Now that our StructDef is registered, we can use it in a WfSpec for defining an input variable.

tip

If you aren't familiar with WfSpecs and how to register them, read the Workflows concepts page before continuing.

In the following WfSpec, we will define a Struct variable input-car that depends on our StructDef car's schema. We will then pass the input-car object into our describe-car task.

package io.littlehorse.quickstart;

import io.littlehorse.sdk.common.config.LHConfig;
import io.littlehorse.sdk.wfsdk.WfRunVariable;
import io.littlehorse.sdk.wfsdk.Workflow;

public class RegisterWorkflow {
public static final String WF_NAME = "quickstart";

public static void main(String[] args) {
LHConfig config = new LHConfig();

Workflow workflowGenerator = Workflow.newWorkflow(WF_NAME, wf -> {
// We will pass our StructDef class into the `WorkflowThread#declareStruct()` method
WfRunVariable inputCar = wf.declareStruct("input-car", Car.class).required();

wf.execute("describe-car", inputCar);
});

workflowGenerator.registerWfSpec(config.getBlockingStub());
}
}

Run the WfSpec

Finally, we will execute a WfRun and pass in a Struct car as an input variable.

In Java, we can convert an instance of our StructDef class into a LittleHorse VariableValue Struct using the LHLibUtil.objToVarVal() method.

Once we've converted our Java object into a VariableValue Struct, we can pass it into a RunWfRequest call.

package io.littlehorse.examples;

import io.littlehorse.sdk.common.LHLibUtil;
import io.littlehorse.sdk.common.config.LHConfig;
import io.littlehorse.sdk.common.proto.RunWfRequest;
import io.littlehorse.sdk.common.proto.VariableValue;

public class RunWorkflow {
public static void main(String[] args) {
LHConfig config = new LHConfig();

// Create an instance of our Car
Car inputCar = new Car("Pontiac", "Aztek", 2005);

// Convert it to a LittleHorse VariableValue
// (this builds the Struct)
VariableValue inputCarValue = LHLibUtil.objToVarVal(inputCar);

// Run the workflow
config.getBlockingStub().runWf(RunWfRequest.newBuilder()
.setWfSpecName("quickstart")
.putVariables("input-car", inputCarValue)
.build()
);
}
}

Accessing Struct fields in a WfSpec

You can use the .get() method to access specific fields of a Struct variable in your WfSpecs:

WfRunVariable inputCar = wf.declareStruct("input-car", Car.class).required();

wf.execute("describe-car-brand", inputCar.get("brand"));

Nullable Fields

By default, every field in a StructDef is non-nullable: its value must always be present and match the declared type. However, there are real-world cases where a field genuinely needs to hold a null value—for example, an unknown address or an optional foreign-key reference—and where a synthetic default like an empty string or zero would be misleading.

The is_nullable flag on a StructFieldDef explicitly allows a field's value to be null (VALUE_NOT_SET), while preserving the safety guarantee that most fields remain non-nullable.

info

In LittleHorse 1.1, nullable fields are available as a limited preview in the Java SDK. Support for nullable fields in other SDKs and languages is coming soon.

Nullability vs. Default Values

Nullability and default values are orthogonal concepts:

is_nullableHas default_valueBehavior
falseNoRequired. The field must be provided in every Struct instance. Cannot be added or removed in a fully-compatible schema update.
falseYesOptional with default. If the field is absent, the default value is used. Can be added or removed in a fully-compatible schema update. A null default_value on a non-nullable field is rejected at PutStructDef time.
trueNoNullable, implicit null default. If the field is absent, it defaults to null. Can be added or removed in a fully-compatible schema update.
trueYesNullable with explicit default. If the field is absent, the provided default is used. The field's value can still be explicitly set to null at runtime.
info

A non-nullable field with a null default_value is incoherent and will be rejected by the server when you call PutStructDef.

Declaring Nullable Fields

In Java, mark a field as nullable using the @LHStructField(isNullable = true) annotation:

import io.littlehorse.sdk.worker.LHStructDef;
import io.littlehorse.sdk.worker.LHStructField;

@LHStructDef("person")
public class Person {
private String firstName;
private String lastName;

@LHStructField(isNullable = true)
private Address homeAddress;

// constructors, getters, setters...
}

When a nullable field is null on the Java object, the SDK serializes it as an empty VariableValue (VALUE_NOT_SET) in the protobuf Struct.

Handling Null Values in Task Workers

When a nullable field is null at runtime, the deserialized object in your task worker will have a null value for that field. Your task worker code should check for this:

@LHTaskMethod("mail-ticket")
public String mailTicket(Person person) {
if (person.getHomeAddress() == null) {
return "Ticket queued for manual follow-up for %s".formatted(person);
}
return "Ticket sent to %s at %s".formatted(person, person.getHomeAddress());
}

Server-Side Validation

The server enforces nullable semantics at two points:

  1. At PutStructDef time: A non-nullable field with a null default_value is rejected with a StructDefValidationException.
  2. At Struct ingress: When a Struct is passed to the server (e.g., as a WfRun input variable or a TaskRun result), null values on non-nullable fields are rejected with a StructValidationException.

Schema Evolution

When you register a StructDef with allowed_updates set to FULLY_COMPATIBLE_SCHEMA_UPDATES, the server allows backward-and-forward compatible changes to the schema. The rule is:

  • You may add or remove fields that have a default value or are nullable.
  • You may not add or remove required fields (fields that are neither nullable nor have a default value).

This rule ensures that both old and new producers/consumers can work with the schema: an absent field always has a known resolution (either the default value or null).

For more details on the StructDefCompatibilityType enum, see the API Reference.

Type-Safety Guarantees

So far, we've seen a happy path scenario for how StructDefs can be used to define a blueprint for your complex data objects. But the real magic in StructDefs lies in the compile-time validations they provide and the errors they'll throw if your Struct doesn't match the expected StructDef.

When your StructDef class diverges from the StructDef on the server

If your local StructDef class diverges from the StructDef registered on the server—for instance, when a required field is added or removed—the server will catch the schema mismatch and reject your Struct.

This ensures that your schema is consistent amongst all clients interfacing with a server instance.

Calling .get() on a field that doesn't exist

If you try to call the .get() method in your WfSpec for a field that doesn't exist, the server will reject your PutWfSpec request instantly. No more guessing if a field will exist at runtime—the server guarantees that any Struct entering your workflow will match the corresponding StructDefs schema.

This is a major advantage that Structs have over our primitive type JSON_OBJ. JSON_OBJs have no schema, so you can't predict their structure when designing a WfSpec. In practice, this opens you up to runtime errors when a JSON_OBJ is malformed.

Further Resources

Congrats on learning how to use StructDefs in LittleHorse! This feature introduces a robust way to handle structured data in LittleHorse, enhancing type safety and usability.

Check out the following resources to keep learning about StructDefs and see other examples of how they can be used: