Skip to main content
Version: 1.3

StructDefs and Structs

A Struct is a strongly typed structured object in LittleHorse. Its schema can come from a registered, named StructDef or, as of LittleHorse 1.3, from an InlineStructDef embedded where the type is used.

Concepts

A Struct is a complex data type represented as a map of fields and values. A Struct schema defines the names, types, and constraints of those fields. These concepts allow for strong typing in workflows, replacing the less structured JSON_OBJ and JSON_ARR types over time.

  • A named StructDef is a registered Metadata Object that provides a reusable, versioned schema.
  • An InlineStructDef embeds the schema directly in a TypeDefinition and has no independent registration or version.
  • A Struct instance holds the actual data at runtime, similar to a JSON_OBJ.

Both schema forms provide the same field-level type validation. Choose based on how the schema should be owned:

Use a named StructDef when...Use an InlineStructDef when...
The schema is reused by multiple metadata objects.The schema belongs to one workflow, task, event, or containing Struct.
The schema needs an independently managed version and compatibility policy.The schema should evolve with the metadata object that contains it.
Clients should reference the schema by name.Registering and naming a separate schema would add unnecessary indirection.

This page focuses on named StructDefs. See InlineStructDefs for embedded schemas and the Java SDK APIs introduced in LittleHorse 1.3.

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 the schema in its entrypoint's TypeDefinition.

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

During this validation process, the server ensures that each required field in the schema exists in the Struct and that every field's value matches the corresponding TypeDefinition.

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: The following example uses a named StructDef. To use this schema form:

  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;
import io.littlehorse.sdk.worker.LHStructField;

@LHStructDef("car")
public class Car {
@LHStructField(description = "The vehicle manufacturer.")
private String make;

@LHStructField(description = "The manufacturer model name.")
private String model;

@LHStructField(description = "The model year.")
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

Nullable fields are stable in the Java SDK as of LittleHorse 1.2. Support for nullable fields in other SDKs may be added in future releases.

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

StructDefs are versioned. The first registered definition has version 0. Registering an accepted change with the same name creates the next version; registering an identical definition is idempotent and does not create a new version.

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 server rejects an update that:

  • Adds a required field (one that is neither nullable nor has a default value).
  • Removes an existing required field.
  • Changes the TypeDefinition of an existing required field.

Consequently, you may add or remove fields that have a default value or are nullable. This gives both old and new producers a defined value for fields they do not know about. For changes outside these limits, create a new StructDef name and update the dependent definitions deliberately.

Referencing StructDef Versions from WfSpecs

When a WfSpec is registered, the server resolves a StructDef reference to a specific version. A compatible StructDef update does not require you to immediately revise every existing WfSpec: those definitions remain pinned to the version they were registered with and continue to work.

When you next register a revision of a WfSpec, its Struct variables can advance to a newer version of the same StructDef. This is also allowed for frozen public or required entrypoint variables, provided the reference keeps the same StructDef name and moves forward to an equal or newer version. Changing the variable to a different fundamental type, changing its StructDef name, or moving it to an older version is still rejected.

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.

StructDef Name Templates

When an application registers StructDefs for multiple environments, the @LHStructDef name can include placeholders set by your environment variables or LHConfig. Use ${key} for a required placeholder, or ${key:default} to use a fallback when the placeholder map has no value for key.

@LHStructDef("car-${environment:local}")
public class Car {
// fields, constructors, getters, and setters...
}

This annotation resolves to car-local when no environment value is supplied, or to car-production when the placeholder map contains environment=production. The SDK resolves placeholders when it registers the definition or uses the class in a workflow.

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: