Skip to main content

Nexus Client Code Generator

View Markdown

A Nexus Service is a contract meant to be shared across team boundaries. Those teams often work in different languages, so the same request and response types get hand-written for each SDK. Hand-written copies drift: a field is required on one side and optional on the other, a bound is enforced by the caller but not the handler.

The Nexus Client Code Generator removes those copies. You describe your types and Nexus Operations once in a definition file, and the generator emits the equivalent library code for Go, Java, Python, and TypeScript. The generator is a command-line tool named nexgen, distributed from the temporalio/nexgen repository.

caution

nexgen is pre-release software and may not retain backwards compatibility with previous versions of the tool. It is not yet published to any package registry, so you build it from source as described in Install the generator.

What the generator produces

The generator produces a client library in Go, Java, Python, or TypeScript for the inputs and outputs of your Nexus Operations. The generated types check values against the contract as they are sent and received, so a violation surfaces as an error rather than as bad data.

That client library contains three things:

  • A typed model. An idiomatic struct, class, interface, or dataclass, with doc comments carried over from the schema.
  • A runtime validator. One validator per type, applied when a value is parsed off the wire and again when it is serialized onto it. See Validation guarantees for more.
  • A Nexus Service Contract definition, for a file that declares Services. These are the Service and Operation declarations you register on a Worker and call from a caller Workflow. A pure JSON Schema file declares none, so it produces only the models and their validators.

Additionally, constraint failures are aggregated into a single native error listing every violation, each naming the offending field and the bound it broke. A handler maps that error to a BAD_REQUEST Nexus error, so a malformed request tells the caller everything that was wrong with it in one response.

The supported schema subset is deliberately strict. Anything ambiguous, or anything that cannot be expressed identically in all generated languages, is rejected when you run the generator with a diagnostic explaining how to express it correctly. The generator prefers to fail loudly at generation time over emitting code that behaves differently in one language than another.

Supported languages

nexgen generates Go, Java, Python, and TypeScript.

Definition files

Types are modeled with JSON Schema 2020-12. A definition file is one of two kinds, decided by what sits at its root. A file is one or the other, never both.

Pure JSON Schema. The root of the document is itself a type, and reusable types live under $defs. Use this when you only need data models shared across languages, with no Service or Operation declarations.

Nexus document. Add a root nexusrpc: "1.0.0" marker to enable a services section. The root becomes an envelope: Services and their Operations sit at the top level, and your types live under $defs. Only this kind can declare a Service.

The two kinds compose across files, so a contract is not limited to one of them. A Service contract is often a Nexus document declaring the Services and Operations, plus pure JSON Schema files holding the types it $refs by relative path. The kb/ closure described below is built that way.

The examples on this page use samples/schemas/chat.nexusrpc.yaml from the repository, abbreviated here:

nexusrpc: '1.0.0'
$schema: https://json-schema.org/draft/2020-12/schema
services:
ChatService:
fqn: example.chat.v1.ChatService
description: Send messages and look up rooms.
operations:
sendMessage:
description: Post a message to a room.
input: { $ref: '#/$defs/SendMessageInput' }
output: { $ref: '#/$defs/SendMessageOutput' }
getRoom:
description: Look up a room by id.
input:
type: object
additionalProperties: false
properties:
roomId: { type: string }
required: [roomId]
output: { $ref: '#/$defs/Room' }
ping:
description: Liveness probe.
$defs:
SendMessageInput:
type: object
additionalProperties: false
properties:
roomId: { type: string }
message: { $ref: '#/$defs/Message' }
required: [roomId, message]
SendMessageOutput:
type: object
additionalProperties: false
properties:
messageId: { type: string }
required: [messageId]

See Definition files in the generator's README for details on that file.

How names are derived

You write two kinds of name in the schema: one for the Service, and one for each Operation. In the sample above they are ChatService and sendMessage:

services:
ChatService: # the Service name
operations:
sendMessage: # the Operation name

Those are the only names you write. From each one the generator produces two more: a wire name and a name in your code. You declare neither of them.

Give each name the casing that matches what it becomes:

  • A Service name is PascalCase: ChatService. A Service becomes a type in the generated code, and types are PascalCase.
  • An Operation name is camelCase: sendMessage. An Operation becomes a method on that type, and the generator cases it like any other member.

The first letter is the part the generator enforces: a Service has to start uppercase and an Operation lowercase. Both must begin with a letter and contain only letters and digits.

service name `chatService` must match `^[A-Z][a-zA-Z\d]+$` (start uppercase, then
letters/digits); set the wire name via `fqn` if it must differ
Overriding the wire name

The fqn in that error — a fully qualified name — is optional, and you can skip it to start.

It lets a Service or Operation carry a wire name of your choosing rather than the one the generator derives from the name you wrote. Because it never becomes a code identifier, it accepts characters a name cannot: that is how a Service gets a wire name of example.chat.v1.ChatService, or an Operation one of poll-messages. Wire names are covered just below.

Use fqn when you need to match a contract that is already published, or when you want a versioned, namespaced wire name. Otherwise leave it out.

The wire name is the string the caller and the handler exchange, and what appears in Event History and the Temporal UI. Neither side types it — both take it from the generated code.

Unless you override it, the wire name is the name from the definition file converted to PascalCase. In the sample that gives the Service a wire name of ChatService and the sendMessage Operation a wire name of SendMessage.

In the sample above the Service does override it using fqn, so the Service wire name becomes example.chat.v1.ChatService.

The name in your code is what you call. The generator recases the name you wrote to match the conventions of the language it is generating for. With no override in play, the two derived names line up like this:

You writeWire nameJavaGoPythonTypeScript
ChatServiceChatServiceChatServiceChatServiceChatServicechatService
sendMessageSendMessagesendMessageSendMessagesend_messagesendMessage

Operations become camelCase methods in Java and TypeScript, snake_case in Python, and PascalCase in Go.

An Operation's input and output are each optional. The ping Operation above declares neither, which generates an Operation that takes and returns nothing. When present, each must be an object type, so that a field can be added later without breaking the wire format.

The repository holds four example definitions under samples/schemas/: chat.nexusrpc.yaml, the feature-diverse showcase.nexusrpc.yaml, the pure-schema temporal.yaml, and a multi-file closure under kb/ showing how types split across files resolve through $ref. The kb/ closure starts at kb.nexusrpc.yaml and pulls in types from its content/ and tree/ subdirectories.

Install the generator

Build the nexgen binary from source with Cargo, the Rust build tool:

git clone https://github.com/temporalio/nexgen.git
cd nexgen
cargo build --release

The binary lands at target/release/nexgen. Confirm it works and check which targets your build supports:

./target/release/nexgen --version
./target/release/nexgen --help

Generate code

Every language uses the same shape: nexgen <language> <input>... --output <dir>. Inputs are positional and may be files or directories, so you can pass a whole multi-file schema closure. Some languages have extra flags.

info

The output directory name becomes the generated package or module name. Name it after your domain, such as chat, not after the language. Pointing --output at a directory named go produces package go, which is not valid Go.

Go

nexgen go samples/schemas/chat.nexusrpc.yaml --output ./chat

Place the output directory inside your Go module. The package name is the directory name, so the example above generates package chat in ./chat/chat.go alongside ./chat/definitions.go.

Java

Java requires --package-name. Point --output at the full package path beneath your source root, not just a directory named after the last segment:

nexgen java samples/schemas/chat.nexusrpc.yaml \
--output ./src/main/java/com/example/chat \
--package-name com.example.chat

The generator checks only that the package name's last dot-separated segment matches the output directory's name. If they disagree, generation stops and tells you how to reconcile them:

`--package-name com.example.wrong` must end with the output directory name `chat`,
but its last segment is `wrong`; point `--output` at a directory named `wrong` or
change the package's last segment to `chat`
Passing that check is not enough to compile

The check compares one segment; Java requires the file's location to match its whole package declaration. --output ./src/chat --package-name com.example.chat passes, because chat matches chat, and still produces files that declare package com.example.chat while sitting at src/chat/.

Nothing fails at generation time, and nothing necessarily fails when you compile the generated files on their own. It breaks when something imports them:

Main.java:1: error: package com.example.chat does not exist
import com.example.chat.ChatService;

Always give --output the entire package path beneath your source root — ./src/main/java/com/example/chat for com.example.chat. If files land in the wrong place, delete them and generate again with a corrected --output, rather than editing the package line to match.

Python

nexgen python samples/schemas/chat.nexusrpc.yaml --output ./chat

This writes an importable package: models.py, services.py, and an __init__.py that re-exports both. Place it where the code that imports it can reach it — the output directory is the package.

That also means the directory name becomes an importable module name, so pick one that does not collide with the standard library. ./chat is safe, but a Service whose subject happens to share a name with a stdlib module is not: ./email, ./queue, and ./calendar each shadow one for anything on that path. Qualify those — ./calendar_v1.

Python output needs Pydantic

Generated models are Pydantic models. Installing the Python SDK on its own does not bring Pydantic with it, so importing the generated package fails with ModuleNotFoundError: No module named 'pydantic' until you add it. The SDK ships an extra for this:

pip install 'temporalio[pydantic]'

Your Worker and Client then need the Pydantic Data Converter — see Use Pydantic models for the setup.

That converter is not optional if your schema uses any temporal format. datetime.date, datetime.time, and datetime.datetime can only be converted by it, and the generator maps date, time, and date-time onto those types.

TypeScript

nexgen ts samples/schemas/chat.nexusrpc.yaml --output ./chat

TypeScript accepts --date-time-types to choose how date and time fields are represented in memory. There are three choices:

  • string, the default, keeps every date and time field as the RFC 3339 string that appears on the wire. It adds no runtime dependency and round-trips losslessly, but you parse and compare the strings yourself.
  • date maps date-time fields to a JavaScript Date. This is lossy: a Date is a UTC instant, so the original offset is folded away and precision is capped at milliseconds.
  • temporal maps to the TC39 Temporal API, a JavaScript standard for dates and times that is unrelated to Temporal the platform. It preserves the offset and sub-second precision, and requires the Temporal global.

The chat schema has no date or time fields, so this command uses temporal.yaml, which has one field for each of date, date-time, time, and duration:

nexgen ts samples/schemas/temporal.yaml --output ./events --date-time-types temporal

Dates, times, and durations

TypeScript's --date-time-types is the only place you choose how a date or time is represented. Elsewhere the generator decides: Java uses java.time, Python datetime and timedelta, and Go time.Time and time.Duration. Two cases hand you the wire string to work with instead of a date type — format: time in Java, and every date and time format under TypeScript's default string mode.

Whichever type you get, every language writes the same bytes. Dates and times use RFC 3339, which is a profile of ISO 8601. ISO 8601 permits many optional spellings of the same instant, and RFC 3339 narrows them to one so two systems cannot read a timestamp differently. RFC 3339 specifies timestamps rather than durations, so durations follow ISO 8601.

How validation works

Validation is the one behavior the generated types add, so a call can fail with a contract violation that hand-written types would not have caught. Only the wiring of that validation differs between languages.

SDKHow validation reaches the wireExtra step
GoGenerated MarshalJSON and UnmarshalJSON on each modelNone
JavaGenerated Jackson serializer and deserializer on each modelNone
PythonPydantic model validationUse the Pydantic data converter
TypeScriptGenerated mapper classesCall the mapper yourself

In Go, Java, and Python the validator sits in the serialization hook the Temporal data converter already calls, so validation happens on its own once the models are in use. TypeScript requires an explicit call, covered in Validate payloads in TypeScript.

Validation guarantees

The two directions do not check the same things.

Parsing a value off the wire enforces required fields and every value constraint, aggregating all violations into one error. This is the direction that protects a handler from a malformed request.

Serializing a value onto the wire enforces value constraints — lengths, bounds, counts, patterns. It does not report a required field you left unset. The field is omitted from the payload and the peer rejects it, so the failure surfaces as a BAD_REQUEST from the other side rather than as a local error at the point you built the object.

For code that catches and logs a violation, see the per-language examples in Use the generated code.

Use the generated code

Whether the code is generated or written by hand, you use it the same way. It is a Service definition and a set of types: register it on a Worker, and call it from a caller Workflow exactly as described in your SDK's Nexus guide. The one difference is that generated types validate themselves, so a call can fail with a contract violation for you to catch.

Each example below registers a handler, calls the Operation from a caller Workflow, and catches a validation failure.

Go

The generated ChatService value carries the Service name and one typed Operation reference per Operation. Register handlers on a Worker:

service := nexus.NewService(chat.ChatService.ServiceName)

sendMessage := nexus.NewSyncOperation(chat.ChatService.SendMessage.Name(),
func(ctx context.Context, input chat.SendMessageInput, _ nexus.StartOperationOptions) (chat.SendMessageOutput, error) {
return chat.SendMessageOutput{MessageId: store(input)}, nil
})

if err := service.Register(sendMessage); err != nil {
return err
}
w.RegisterNexusService(service)

Call it from a caller Workflow, passing the generated Operation reference so the SDK type-checks the request and response. A payload that violates the contract fails at the call, so that is where you catch it:

client := workflow.NewNexusClient("chat-endpoint", chat.ChatService.ServiceName)

var output chat.SendMessageOutput
err := client.ExecuteOperation(
ctx,
chat.ChatService.SendMessage,
chat.SendMessageInput{RoomId: "r1", Message: chat.Message{Kind: "text", Body: "hi"}},
workflow.NexusOperationOptions{},
).Get(ctx, &output)

if err != nil {
var validationErr *chat.ValidationError
if errors.As(err, &validationErr) {
for _, v := range validationErr.Violations {
logger.Error("contract violation", "path", v.Path, "reason", v.Reason)
}
} else {
logger.Error("Nexus call failed", "error", err)
}
return err
}

ValidationError carries every violation as a Violation with a Path and a Reason. Reach it with errors.As rather than a type assertion, because the error arrives wrapped by the JSON encoder. It is generated into each package, so a consumer of two generated Services needs one errors.As per package.

Java

The generator emits ChatService as an interface annotated with @Service, with one @Operation method per Operation. On the handler side, write a separate implementation class that points at the generated interface with @ServiceImpl, and return an OperationHandler from each @OperationImpl method:

@ServiceImpl(service = ChatService.class)
public final class ChatServiceImpl {
@OperationImpl
public OperationHandler<SendMessageInput, SendMessageOutput> sendMessage() {
return OperationHandler.sync((ctx, details, input) -> new SendMessageOutput(store(input)));
}
}

Register it on a Worker with worker.registerNexusServiceImplementation(new ChatServiceImpl()).

On the caller side, the same interface works directly as a Workflow stub. A payload that violates the contract fails at the call, so that is where you catch it:

ChatService chat = Workflow.newNexusServiceStub(
ChatService.class,
NexusServiceOptions.newBuilder()
.setEndpoint("chat-endpoint")
.setOperationOptions(NexusOperationOptions.newBuilder()
.setScheduleToCloseTimeout(Duration.ofSeconds(10))
.build())
.build());

try {
SendMessageOutput output = chat.sendMessage(new SendMessageInput("r1", message));
} catch (DataConverterException e) {
if (e.getCause() instanceof ValidationException ve) {
ve.getViolations().forEach(v -> log.error("{}: {}", v.getPath(), v.getReason()));
} else {
log.error("Payload conversion failed", e);
}
throw e;
}

ValidationException extends Jackson's JsonMappingException, so it is checked and always arrives wrapped. Converting the payload is what triggers it, so it reaches you as the cause of a DataConverterException, with the violation list intact.

It is generated into each package, so a consumer of two generated Services has two unrelated exception types of the same name and needs a catch per package.

Python

The generator emits ChatService as a @service-decorated class whose attributes are typed Operation declarations. Bind a handler to it:

@service_handler(service=ChatService)
class ChatServiceHandler:
@sync_operation
async def send_message(
self, ctx: StartOperationContext, input: SendMessageInput
) -> SendMessageOutput:
return SendMessageOutput(messageId=store(input))

Pass the handler to your Worker as nexus_service_handlers=[ChatServiceHandler()], then call it from a caller Workflow:

client = workflow.create_nexus_client(service=ChatService, endpoint="chat-endpoint")

output = await client.execute_operation(
ChatService.send_message,
SendMessageInput(roomId="r1", message=Message(kind="text", body="hi")),
)

Generated Python fields are snake_case with the wire name as an alias. Construct models with either name, and read them with the snake_case attribute: SendMessageInput(roomId="r1", ...) constructs, and output.message_id reads.

There is nothing to catch at the call. Pydantic validates when the model is constructed, so an invalid SendMessageInput raises pydantic.ValidationError at the constructor and never reaches the Nexus client. Handle it where you build the model.

TypeScript

The generator emits a chatService Service definition plus, for each type, an interface and a companion <Type>Mapper class:

export const chatService = nexus.service('example.chat.v1.ChatService', {
sendMessage: nexus.operation<SendMessageInput, SendMessageOutput>({ name: 'SendMessage' }),
getRoom: nexus.operation<GetRoomInput, Room>({ name: 'GetRoom' }),
ping: nexus.operation<void, void>({ name: 'Ping' }),
});

Register a handler against that definition with nexus.serviceHandler(chatService, { ... }), and create a caller with workflow.createNexusServiceClient({ service: chatService, endpoint: 'chat-endpoint' }).

Validate payloads in TypeScript

caution

In TypeScript the generated validator only runs when you call the mapper. No generated payload converter exists, so nothing calls it for you.

Each generated type comes with a mapper exposing two methods. fromIntermediate validates an untrusted plain value and returns the typed model. toIntermediate validates a model and returns its plain wire form. Call them at both edges of every Operation, on the handler side and the caller side:

const handler = nexus.serviceHandler(chatService, {
async sendMessage(_ctx, input) {
const request = new SendMessageInputMapper().fromIntermediate(input);
const output = { messageId: await store(request) };
return new SendMessageOutputMapper().toIntermediate(output) as SendMessageOutput;
},
});

The caller side is the mirror image. Map the request out before executing the Operation, and map the result back in when it returns:

const client = workflow.createNexusServiceClient({
service: chatService,
endpoint: 'chat-endpoint',
});

const wire = new SendMessageInputMapper().toIntermediate(input) as SendMessageInput;
const raw = await client.executeOperation(chatService.operations.sendMessage, wire);
const output = new SendMessageOutputMapper().fromIntermediate(raw);

The cast is expected in both examples: toIntermediate returns unknown, because its result is a plain wire value rather than the model type the Operation declares.

Skipping the mapper is the failure to watch for, because nothing reports it. The value handed to your handler is typed as the model, since nexus.operation<SendMessageInput, SendMessageOutput> declares it that way, but at runtime it is only whatever was deserialized. A handler that ignores the mapper compiles, type-checks, and returns correct results for valid payloads, while enforcing none of the constraints in your schema.

When a payload does violate the contract, fromIntermediate throws a ValidationError carrying every violation at once:

ValidationError: 2 validation error(s): roomId: required; message.body: expected string

The error also exposes a violations array of { path, reason } objects, so a handler can convert it into a BAD_REQUEST Nexus error with the full list intact.

Regenerate after a contract change

Generated files carry a DO NOT EDIT header and are replaced wholesale on the next run. There is no merge step, so anything you add to them is lost.

Two habits make this safe:

  • Commit generated code and regenerate as its own commit. The diff then shows exactly what the contract change did to each language.
  • Fix names in the schema, not the output. When a generated identifier is wrong for your language, set a per-language override in the contract so the fix survives regeneration. See Naming and overrides for the available keys.

If you generate into the wrong directory, delete what landed there and run the generator again with corrected flags. Do not edit the package or module declaration to match where the files ended up as the next run will overwrite it.

Schema defaults

A property can declare a default, which makes it optional for a caller to supply:

sampleValue:
type: integer
default: 0

A caller that leaves sampleValue unset sends a payload without the field, and the receiver reads 0. The default is never written into the payload, so an omitted field stays omitted rather than being filled in before it is sent.

Changing a default is a breaking change

The default lives in the generated code, not in the payload. Temporal replays a Workflow by re-reading the payloads already recorded in its Event History using whatever code the Worker is running now, so changing a default changes what those recorded payloads mean.

If the value affects which commands the Workflow produces, replaying an in-flight Workflow fails with a non-determinism error — the deterministic constraints that govern any change to Workflow code apply here too. If it does not affect commands, nothing fails and the behavior changes silently, which is harder to catch.

Treat a default as part of the contract. Adding a replacement field is not sufficient on its own: old payloads omit the new field too, so replay reads that field's default and can still diverge. Any change to how existing payloads are interpreted needs a Workflow versioning plan that keeps in-flight Executions on their original behavior.

Because the field is optional, Go and Java give you two members side by side, so nothing depends on remembering that a default exists:

  • The field itself, which is empty when the caller omitted it — getSampleValue() returns null in Java, and SampleValue is a nil *int64 in Go.
  • An accessor named after it that substitutes the default — getSampleValueOrDefault() and SampleValueOrDefault().

Use the first when you need to know whether the caller supplied a value, and the second when you just want a number.

TypeScript has no accessor. sampleValue is undefined when unset, and the generator exports a DEFAULT_SAMPLE_VALUE constant you apply yourself: sampleValue ?? DEFAULT_SAMPLE_VALUE.

Python has neither. Pydantic applies defaults when the model is constructed, so sample_value always holds a value and an omitted field reads the same as one explicitly set to 0.

Supported schema features

The generator implements a curated subset of JSON Schema 2020-12 chosen so that every accepted construct lowers identically into all generated languages.

Fully supported: properties, required, default, minProperties and maxProperties, dependentRequired, string and numeric bounds, items, minItems and maxItems, minContains and maxContains, allOf, the recognized nullable pattern oneOf: [{type: T}, {type: "null"}], and the title, description, and deprecated annotations.

Partially supported: type (single-string form only), additionalProperties, propertyNames, const and enum (scalars only), format, pattern (a portable RE2-safe subset), multipleOf, contentEncoding, uniqueItems, contains, oneOf (branches must be separable by a decidable selector), and $ref with $defs (local files only).

Deliberately rejected, because they have no coherent typed lowering across all generated languages: anyOf, not, if/then/else, dependentSchemas, prefixItems, unevaluatedProperties, unevaluatedItems, contentMediaType, and contentSchema.

For the current per-keyword support table, see the nexgen README.

RESOURCES