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
StructDefis a registered Metadata Object that provides a reusable, versioned schema. - An
InlineStructDefembeds the schema directly in aTypeDefinitionand has no independent registration or version. - A
Structinstance holds the actual data at runtime, similar to aJSON_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:
- Define and register the
StructDef - Use the
StructDefin aWfSpec,TaskDef, orExternalEventDef. - Pass a matching
Structinto the correspondingWfRun,TaskRun, orExternalEvent.
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.
- Java
- Python
- Go
- TypeScript
- C#
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());
}
}
To define a StructDef in Python, we can define a Python class with the decorator LHStructDef. Each field on the class will be serialized into your StructDef schema.
@lh_struct_def(name="car", description="A car.")
class Car:
make: str
model: str
year: int
To register your StructDef in Python, run the following code:
async def main() -> None:
config = get_config()
wf = get_workflow()
littlehorse.create_struct_def(Car, config)
To define a StructDef in Go, we can define a Go struct and implement the LHStructDef method. Each field on the struct will be serialized into your StructDef schema.
In Go, we don't have annotations or decorators to mark a struct as a StructDef, so we use a convention of implementing the LHStructDef() method to indicate that this struct is a StructDef and to provide its metadata.
It's a good idea to store the StructDef's name and description as constants, making them easy to reference in your WfSpecs when creating Struct variables that depend on this StructDef.
const CarStructDefName = "car"
const CarStructDefDescription = "A car."
type Car struct {
Make string `json:"make"`
Model string `json:"model"`
Year int `json:"year"`
}
func (Car) LHStructDef() littlehorse.LHStructDefInfo {
return littlehorse.LHStructDefInfo{Name: CarStructDefName, Description: CarStructDefDescription}
}
To register your StructDef in Go, run the following code:
err := littlehorse.RegisterStructDef(*client, structs.Car{}, nil);
if err != nil {
log.Fatal(err)
}
To define a StructDef in TypeScript, tag a Zod object schema with lhStruct(). The same schema provides the inferred TypeScript type and the metadata needed for registration.
import {
buildPutStructDefRequest,
LHConfig,
lhStruct,
} from "littlehorse-client";
import { z } from "zod";
const Car = lhStruct(
"car",
z.object({
make: z.string(),
model: z.string(),
year: z.number().int(),
}),
);
type Car = z.infer<typeof Car>;
const config = LHConfig.from({});
const client = config.getClient();
await client.putStructDef(buildPutStructDefRequest(Car));
To define a StructDef in .NET, we can define a C# class with the LHStructDef attribute. Each field on the class will be serialized into your StructDef schema.
[LHStructDef("car")]
public class Car
{
public string Make { get; set; } = string.Empty;
public string Model { get; set; } = string.Empty;
public int Year { get; set; }
public Car()
{
}
public Car(string make, string model, int year)
{
Make = make;
Model = model;
Year = year;
}
}
To register your StructDef in .NET, run the following code:
var structDefType = new LHStructDefType(typeof(Car));
var request = new PutStructDefRequest
{
Name = structDefType.GetStructDefId().Name,
Description = structDefType.GetStructDefDescription(),
StructDef = structDefType.GetInlineStructDef(),
AllowedUpdates = StructDefCompatibilityType.NoSchemaUpdates
};
await client.PutStructDefAsync(request);
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.
If you aren't familiar with TaskDefs and how to register them, read the Workflows concepts page before continuing.
- Java
- Python
- Go
- TypeScript
- C#
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();
}
}
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.
def describe_car(car: Car) -> str:
return f"You drive a {car.make} {car.model}"
Using a StructDef as a parameter for your task is simple. Just reference the StructDef struct in your Task Signature as you would with any other type.
func DescribeCar(car Car) string {
return fmt.Sprintf("You drive a %s %s", car.Make, car.Model)
}
Using a StructDef as a task parameter is simple. Infer the task's parameter type from the same Zod schema used to register the StructDef.
function describeCar(car: z.infer<typeof Car>): string {
return `You drive a ${car.make} ${car.model}`;
}
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.
public string DescribeCar(Car car)
{
return $"You drive a {car.Make} {car.Model}";
}
Use the StructDef in a WfSpec
Now that our StructDef is registered, we can use it in a WfSpec for defining an input variable.
If you aren't familiar with WfSpecs and how to register them, read the Workflows concepts page before continuing.
- Java
- Python
- Go
- TypeScript
- C#
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());
}
}
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.
def quickstart_wf() -> Workflow:
def my_entrypoint(wf: WorkflowThread):
input_car = wf.declare_struct("input_car", Car).required()
wf.execute("describe_car", input_car)
return Workflow("quickstart", my_entrypoint)
In the following WfSpec, we will define a Struct variable inputCar that depends on our StructDef Car's schema. We will then pass the inputCar object into our describeCar task.
In this case, we're referencing the StructDef by name using a constant CarStructDefName that we defined on our Car struct in the structs package.
func QuickstartWorkflow(wf *littlehorse.Workflow) {
inputCar := wf.DeclareStruct("inputCar", structs.CarStructDefName).Required()
wf.Execute("describe-car", inputCar)
}
In this WfSpec, the input-car variable uses the Car schema and is passed to the describe-car task.
import { LHConfig, Workflow } from "littlehorse-client";
const config = LHConfig.from({});
const workflow = Workflow.newWorkflow("quickstart", (wf) => {
const inputCar = wf.declareStruct("input-car", Car).required();
wf.execute("describe-car", inputCar);
});
await workflow.registerWfSpec(config);
In the following WfSpec, we will define a Struct variable inputCar that depends on our StructDef Car's schema. We will then pass the inputCar object into our DescribeCar task.
public class Program
{
private static Workflow GetQuickStartWorkflow()
{
void MyEntrypoint(WorkflowThread wf)
{
var inputCar = wf.DeclareStruct("inputCar", typeof(Car)).Required();
wf.Execute("describe-car", inputCar);
}
return new Workflow("quickstart", MyEntrypoint);
}
}
Run the WfSpec
Finally, we will execute a WfRun and pass in a Struct car as an input variable.
- Java
- Python
- Go
- TypeScript
- C#
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()
);
}
}
In Python, we can convert an instance of our StructDef class into a LittleHorse VariableValue Struct using the littlehorse.lh_struct.serialize_to_struct() method.
import littlehorse
from littlehorse.config import LHConfig
from littlehorse.lh_struct import serialize_to_struct
from littlehorse.model import VariableValue
from littlehorse.model import RunWfRequest
async def main() -> None:
config = get_config()
stub = config.stub()
# Create an instance of our Car
input_car = Car(make="Pontiac", model="Aztek", year=2005)
# Convert it to a LittleHorse VariableValue
# (this builds the Struct)
input_car_value = serialize_to_struct(input_car)
# Run the workflow
stub.RunWf(RunWfRequest(
wf_spec_name="quickstart",
variables={"input_car": VariableValue(struct=input_car_value)}
))
In Go, we can convert an instance of our StructDef struct into a LittleHorse VariableValue Struct using the littlehorse.ToLhStruct() method.
package main
func main() {
config, err := littlehorse.NewConfigFromProps("${HOME}/.config/littlehorse.config")
if err != nil {
log.Fatal(err)
}
client, err := config.GetGrpcClient()
if err != nil {
log.Fatal(err)
}
car := structs.Car {
Make: "Pontiac",
Model: "Aztek",
Year: 2005,
}
carStruct, err := littlehorse.ToLhStruct(car)
if err != nil {
log.Fatal(err)
}
(*client).RunWf(
context.Background(),
&lhproto.RunWfRequest{
WfSpecName: "quickstart",
Variables: map[string]*lhproto.VariableValue{
"input-car": {
Value: &lhproto.VariableValue_Struct{
Struct: carStruct,
},
},
},
})
}
In TypeScript, toStructVariableValue() validates and converts an object using its tagged Zod schema.
import { LHConfig, toStructVariableValue } from "littlehorse-client";
const config = LHConfig.from({});
const client = config.getClient();
const inputCar: Car = {
make: "Pontiac",
model: "Aztek",
year: 2005,
};
await client.runWf({
wfSpecName: "quickstart",
variables: {
"input-car": toStructVariableValue(inputCar, Car),
},
});
In C#, we can convert an instance of our StructDef class into a LittleHorse VariableValue Struct using the LHMappingHelper.ObjectToVariableValue() method.
public class Program
{
private static async Task RunWorkflow()
{
var config = GetLHConfig(loggerFactory);
var client = config.GetGrpcClientInstance();
var car = new Car(
Make: "Pontiac",
Model: "Aztek",
Year: 2005
);
await client.RunWfAsync(new RunWfRequest
{
WfSpecName = "quickstart",
Variables =
{
{ "input-car", LHMappingHelper.ObjectToVariableValue(car) }
}
});
}
}
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.
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_nullable | Has default_value | Behavior |
|---|---|---|
false | No | Required. The field must be provided in every Struct instance. Cannot be added or removed in a fully-compatible schema update. |
false | Yes | Optional 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. |
true | No | Nullable, implicit null default. If the field is absent, it defaults to null. Can be added or removed in a fully-compatible schema update. |
true | Yes | Nullable 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. |
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
- Java
- TypeScript
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.
In TypeScript, use a nullable Zod field. The SDK emits isNullable in the registered StructDef and serializes null as VALUE_NOT_SET.
const Address = lhStruct(
"address",
z.object({
street: z.string(),
city: z.string(),
}),
);
const Person = lhStruct(
"person",
z.object({
firstName: z.string(),
lastName: z.string(),
homeAddress: Address.nullable(),
}),
);
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:
- At
PutStructDeftime: A non-nullable field with a nulldefault_valueis rejected with aStructDefValidationException. - At
Structingress: When aStructis passed to the server (e.g., as aWfRuninput variable or aTaskRunresult), null values on non-nullable fields are rejected with aStructValidationException.
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
TypeDefinitionof 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:
- InlineStructDefs — embed a Struct schema directly in another type without registering a separate
StructDef. - Arrays —
Structtypes can be used as elements in nativeARRAYvariables, and struct fields can be typed arrays using@LHStructField(isLHArray = true). - StructDefs - Java Example — includes a nullable field demo (run with a
NOADDR-prefixed license plate). - API Reference: StructFieldDef — protobuf field definitions including
is_nullable. - API Reference: StructDefCompatibilityType — schema evolution compatibility rules.