Authoring templates

March 25, 2026 · View on GitHub

Output of the code generator is driven by a set of templates that are stored in the tool package's internal templates directory and are installed with the tool.

Template authors write text files that are processed by the template engine. You don't have to write any extensions or plugins to use the template engine. The template language, which is an extension of the Jinja2 template language, is built into the tool and has a set of extensions specific to code generation that cover even fairly complicated scenarios.

Custom templates can be provided by the user by creating a directory that has the same structure as this templates directory and passing the path to the --templates command line argument. Any templates found in the user-provided directory will override the corresponding language and style of the built-in templates and you can add new languages and styles.

The generator distinguishes between templates for endpoints and message definition groups (those are the styles).

Directory structure

The content of the templates directory is dynamic and extensible.

The common structure looks like this:

templates
├── {language}
│   ├── _templateinfo.json
│   ├── _common
│   │   ├── amqp.jinja.include
│   │   ├── cloudevents.jinja.include
│   │   └── mqtt.jinja.include
│   ├── {style}[/subdir]
│   │   ├── _templateinfo.json
│   │   ├── {template}.jinja
│   │   └── ...
│   └── ...
└── ...

In a templates directory reside subdirectories for each of the supported output languages. Adding support for a new language is as simple as adding a new directory with a unique name. That name should correspond to the common file name suffix for the language, e.g. if you were adding support for C++ you would name the directory cpp.

The tool uses the name of the directory as the language identifier for the --language command line argument. The language identifier is also used to find the correct templates for automatic generation of schema serializer code as shown in the illustration above.

Under each language directory, there are subdirectories for each template set. Template sets are called "styles" to avoid conflations with other overloaded terms. The tool uses the name of the subdirectory as the style identifier for the --style command line argument.

The _common directory may include files for template macros that are shared across all styles. Other directories that start with an underscore are ignored and reserved for internal/future use.

The _common directory is optional. If you don't need to share macros across styles, you don't need to provide any templates in the _common directory.

The full set of templates for the C# language is in the xrcg/templates/cs directory.

Template Info

For tools that wrap the code generator, like the VS Code extension, you can place a _templateinfo.json file into the language directory to provide metadata for the language:

{
  "description": "C# / .NET 6.0+",
  "priority": 1
}

Inside each style directory, you can place a _templateinfo.json file that provides metadata for the style. This file supports the following properties:

Template Info Properties

description (string)

A human-readable description of the template language or style. This is displayed in tools like the VS Code extension to help users understand what the template generates.

Example: "C# AMQP 1.0 Producer", "TypeScript Apache Kafka Consumer"

priority (number, default: 100)

An integer value used for sorting the list of templates in tools. Lower numbers appear higher in the list, indicating higher priority or importance. The default priority is 100, which places templates at/near the bottom of the list.

Example: 1 (high priority), 50 (medium priority), 100 (default/low priority)

main_project_name (string template, optional)

Specifies the name of the main project to be generated. This overrides the default project name. Supports template syntax with placeholders and filters.

Example: "{project_name|pascal}AmqpProducer", "{project_name}KafkaConsumer"

data_project_name (string template, optional)

Specifies the name of the data/schema project to be generated. This is used for organizing generated data classes separately from the main application code. Supports template syntax with placeholders and filters.

Example: "{project_name|pascal}Data", "{project_name}Schemas"

data_project_dir (string template, optional)

Specifies the directory name for the data project. If not specified, defaults to the value of data_project_name. Supports template syntax with placeholders and filters.

Example: "{project_name|snake}_data", "schemas/{project_name}"

src_layout (boolean, optional)

When true, indicates that the template uses a source layout convention (e.g., putting code under a src/ directory). This affects how the generator organizes the output directory structure.

Example: true or false

Template String Syntax

String properties like main_project_name and data_project_name support a template syntax for dynamic value resolution:

Placeholders:

  • {variable_name} - replaced with the value of the variable

Filters:

  • {variable|filter} - apply a filter to transform the value
  • {variable|filter1|filter2} - chain multiple filters

Path Suffixes:

  • {variable~path~segment} - append path segments with / separators

Available Variables:

The following variables are available depending on the context where resolve_string is used:

For _templateinfo.json properties (main_project_name, data_project_name, data_project_dir):

  • project_name - the project name provided via --projectname argument

Available Filters:

  • lower - converts to lowercase
  • upper - converts to uppercase
  • pascal - converts to PascalCase
  • camel - converts to camelCase
  • snake - converts to snake_case
  • dotdash - replaces . with -
  • dashdot - replaces - with .
  • dotunderscore - replaces . with _
  • underscoredot - replaces _ with .

Examples:

{
  "main_project_name": "{project_name|pascal}Producer",
  "data_project_name": "{project_name|snake}_data",
  "data_project_dir": "{project_name~schemas}"
}

Complete Example

{
  "description": "C# Azure Event Hubs Consumer",
  "priority": 5,
  "src_layout": true,
  "main_project_name": "{project_name|pascal}EventHubsConsumer",
  "data_project_name": "{project_name|pascal}Data",
  "data_project_dir": "{project_name|pascal}Data"
}

Template styles

All code templates live grouped in style directories, each set typically reflecting a full code project, including project files and other assets.

Code generation approach

The general code generation philosophy is that the code generator should yield code and assets that can be compiled and packaged without any further changes and that the code contains extensibility hooks to integrate it into application projects.

For instance, the code for C# consumers that is generated by the embedded templates always includes a "dispatch interface" that can be implemented by the application to handle messages. The dispatch interface shape is identical across all transports and hosts but differs by message definition format. Shows here is the dispatch interface for C# consumers that use the CloudEvents format:

namespace Contoso.ERP.Consumer
{
    public interface IEventsDispatcher
    {
        Task OnReservationPlacedAsync(CloudEvent cloudEvent, OrderData data);
        Task OnPaymentsReceivedAsync(CloudEvent cloudEvent, PaymentData data);
        ...
    }
}

The implementation of the interface is then passed to the generated consumer class, for instance in the factory methods that create the consumer instance for a declared endpoint.

var consumer = new EventsEventConsumer(endpoint, eventsDispatcher);

In scenarios like Azure Functions, the dispatcher is added by dependency injection.

 private static void Main(string[] args)
{
    var host = new HostBuilder()
        .ConfigureServices(s =>
        {
            s.AddSingleton<IEventsDispatcher, MyEventsDispatcher>();
        })
        .Build();
    host.Run();
}

Code template files

All files you want to emit must be suffixed with .jinja and are run through the Jinja template engine. If you don't put any Jinja template syntax in your file, it will be copied verbatim to the output directory, with the ".jinja" suffix stripped.

If you prefix a file with an underscore, it will be processed after all regular files and also after all schema files have been generated. This is useful for generating files that must know about all the generated classes or files, like a project file. The underscore prefix is stripped from the output file name.

Template File Names

Template file names determine output file names and can include special macros for dynamic naming and multi-file generation. The built-in templates demonstrate all these patterns.

Basic File Naming

The simplest approach is a static file name. The template engine uses it as-is (minus the .jinja extension).

Examples from built-in templates:

Template FileOutput FileUsed In
package.json.jinjapackage.jsonTypeScript Kafka producer
pyproject.toml.jinjapyproject.tomlPython Kafka producer
pom.xml.jinjapom.xmlJava Kafka producer
.gitignore.jinja.gitignoreTypeScript templates

File Name Expansion Macros

The generator supports several macros in file names that expand dynamically.

{projectname} — Project Name

Expands to the --projectname argument value.

Real example: The AsyncAPI template uses {projectname}.yml.jinja which generates ContosoEvents.yml when --projectname ContosoEvents.

{mainprojectname} — Main Project Name

Expands to the main project name (may differ from projectname if _templateinfo.json defines main_project_name).

Real example: The C# Kafka producer uses {mainprojectname}.csproj.jinja in src/ to generate the project file with the correct name.

{rootdir} — Output Root Directory

Places the file at the output root, regardless of the template's subdirectory location.

Rationale: Templates often live in src/ or test/ subdirectories but need to emit files like README.md or .sln at the project root.

Real examples from C# Kafka producer:

Template LocationTemplate NameOutput
kafkaproducer/{rootdir}README.md.jinjaREADME.md (at root)
kafkaproducer/{rootdir}{mainprojectname}.sln.jinjaContosoEvents.sln

{testdir} — Test Directory

Expands to the test directory path (tests/ or ../tests/ depending on context).

Real example: The Python Kafka producer uses {testdir}test_producer.py.jinja to place tests in the correct location.

{classdir} — Package Directory Structure

Creates subdirectories reflecting the package/namespace structure. The template is invoked once per message group or type, with root scoped to that item.

Rationale: Java requires source files in directories matching their package structure. This macro handles that automatically.

Real example: The Java Kafka producer uses:

src/main/java/{classdir}Producer.java.jinja
src/main/java/{classdir}EventFactory.java.jinja

For a message group com.contoso.orders, this generates:

src/main/java/com/contoso/orders/Producer.java
src/main/java/com/contoso/orders/EventFactory.java

Filename Filters with ! Syntax

Filters can be applied in filenames using ! as the separator (since | isn't valid in filenames).

Real example: The Python Kafka producer uses:

src/{mainprojectdir!dotunderscore!lower}producer.py.jinja
src/{mainprojectdir!dotunderscore!lower}__init__.py.jinja

This chain:

  1. Takes mainprojectdir (e.g., Contoso.Events)
  2. Applies dotunderscoreContoso_Events
  3. Applies lowercontoso_events

Result: src/contoso_events/producer.py

Rationale: Python modules must be lowercase with underscores, while project names often use dots or PascalCase.

Path Segments with ~ Syntax

The ~ operator appends path segments.

Real example: The Python Kafka producer uses:

{rootdir~samples}sample.py.jinja
{rootdir~scripts}build.py.jinja

This generates:

samples/sample.py
scripts/build.py

Scoped Document Context

When using {classdir} or {classname} macros, the template receives a modified root variable scoped to the current item:

  • The template is invoked once per message group (or type)
  • root.messagegroups contains only the single message group being processed
  • root.schemagroups and root.endpoints remain available for cross-references

This allows a single template to generate multiple files, one per message group, with each invocation seeing only its relevant data.

Template variables

The input document, which is either an xRegistry document or a schema document, is passed to the template as a variable named root. The root variable's structure reflects the respective input document.

  • For code generators for message payload schemas, the root variable is the root of the xRegistry document, corresponding to the xRegistry schema type document. Underneath root are three collections:
    • messagegroups - a dictionary of message definition groups, keyed by the message group's ID.
    • schemagroups - a dictionary of schema definition groups, keyed by the message group's ID.
    • endpoints - a dictionary of endpoints, keyed by the endpoint's ID.

As discussed above, the {classdir} and {classfile} filename expansion macros will modify the input document given to the template and scope it to the current object that shall be emitted.

Otherwise, the template always gets the full input document.

If the --definitions argument points to a URL or file name that returns/contains a fragment of an xRegistry document, such as a single schemagroup or messagegroup, the generator will synthesize a full xRegistry document around the fragment and pass it to the template.

Filters

In addition to the many filters built into Jinja, the following extra filters are available for use in templates. Examples are drawn from actual built-in templates.

Case Conversion Filters

pascal

Converts a string (including those in camelCase and snake_case) to PascalCase. Handles namespace separators (. and ::).

Used in: All language templates for class names, method names, and type references.

Real example from C# Kafka producer:

{%- set groupname = messagegroupid | pascal -%}
{%- set class_name = (groupname | strip_namespace) + "Producer" %}

With messagegroupid = "contoso.orders", this produces class name ContosoOrdersProducer.

camel

Converts a string (including those in snake_case and PascalCase) to camelCase. Handles namespace separators (. and ::).

Used in: TypeScript and Java templates for variable and method names.

Example:

{{ "foo_bar" | camel }} -> fooBar
{{ "FooBar" | camel }} -> fooBar
snake

Converts a string (including those in camelCase and PascalCase) to snake_case. Handles namespace separators (. and ::).

Used in: Python templates for module and variable names.

Real example from Java Kafka producer:

{%- set package_name = project_name | lower | replace('-', '_') %}

Example:

{{ "fooBar" | snake }} -> foo_bar
{{ "FooBar" | snake }} -> foo_bar

String Manipulation Filters

dotdash

Replaces dots with dashes in a string. Useful for package names in URLs or file paths.

Example:

{{ "com.example.Foo" | dotdash }} -> com-example-Foo
dashdot

Replaces dashes with dots in a string.

Example:

{{ "com-example-Foo" | dashdot }} -> com.example.Foo
dotunderscore

Replaces dots with underscores in a string.

Used in: Python templates for converting project names to valid module names.

Real example from Python Kafka producer:

{%- set import_statement = "from " + (data_project_name | dotunderscore | lower) + " import " + class_name %}

With data_project_name = "Contoso.Events.Data", this produces from contoso_events_data import OrderData.

underscoredot

Replaces underscores with dots in a string.

Example:

{{ "com_example_Foo" | underscoredot }} -> com.example.Foo
lstrip(prefix)

Strips a prefix from a string.

Example:

{{ "prefix_value" | lstrip("prefix_") }} -> value
pad(len)

Left-justifies a string with spaces to the specified length. This is useful for aligning version strings in templates.

Example:

{{ "1.0" | pad(5) }} -> "1.0  "
{{ "11.0" | pad(5) }} -> "11.0 "
strip_invalid_identifier_characters

Strips invalid characters from an identifier. This is useful for converting strings to identifiers in languages that have stricter rules for identifiers. All unsupported characters are replaced with an underscore.

Real example from TypeScript Kafka producer:

{%- set data_module_name = data_project_name | strip_invalid_identifier_characters %}
import * as {{ data_module_name }} from '../../{{ data_project_name }}/dist/index.js';

With data_project_name = "Contoso-Events.Data", produces import alias Contoso_Events_Data.

Namespace Manipulation Filters

strip_namespace

Strips the namespace/package portion off an expression, leaving only the class name.

Used in: C# and Java templates for extracting class names from fully-qualified type references.

Real example from C# Kafka producer:

{%- set class_name = (groupname | strip_namespace) + "Producer" %}

With groupname = "Contoso.Orders", this produces OrdersProducer.

namespace(namespace_prefix="")

Gets the namespace/package portion of an expression, optionally prepending a prefix.

Example:

{{ "com.example.Foo" | namespace }} -> com.example
{{ "Foo" | namespace("com.example") }} -> com.example
namespace_dot(namespace_prefix="")

Gets the namespace portion of an expression followed by a dot, or an empty string if no namespace exists.

Example:

{{ "com.example.Foo" | namespace_dot }} -> com.example.
{{ "Foo" | namespace_dot }} -> ""
concat_namespace(namespace_prefix="")

Concatenates the namespace/package portions of an expression with an optional prefix.

Example:

{{ "com.example.Foo" | concat_namespace }} -> com.example.Foo
strip_dots

Removes all dots from a string, concatenating namespace portions.

Example:

{{ "com.example.Foo" | strip_dots }} -> comexampleFoo

Format Conversion Filters

toyaml(indent=4)

Formats the given object as YAML with specified indentation. This is useful for emitting parts of the input document, for instance JSON Schema elements, into YAML documents. tojson is already built into Jinja.

Example:

{{ root | toyaml }}
{{ root | toyaml(2) }}
proto

Formats a proto text string with proper indentation and spacing.

Example:

{{ proto_text | proto }}
go_type

Converts a type name to a Go type. Handles primitive types, package prefixes, and PascalCase conversion for Go conventions.

Example:

{{ "string" | go_type }} -> string
{{ "projectData.OrderInfo" | go_type }} -> project_data.OrderInfo

Search and Pattern Matching Filters

exists(prop, value)

Recursively checks whether the given property exists anywhere in the given object scope with the value prefixed with the given string (case-insensitive).

Used in: Common include files for conditional protocol handling.

Real example from Java CloudEvents include:

{%- if root | exists("envelope","CloudEvents/1.0") %}
import io.cloudevents.CloudEvent;
import io.cloudevents.core.builder.CloudEventBuilder;
{%- endif %}

Rationale: Templates often need to conditionally include imports or code blocks based on what envelope formats or protocols are used in the message definitions.

existswithout(prop, value, propother, valueother)

Checks whether a property exists with a value prefix, but only if another property does not exist with another value. Useful for conditional checks in templates.

Example:

{% if root | existswithout("format", "amqp", "encoding", "binary") %}
    // do something
{% endif %}
regex_search(pattern)

Performs a regex search. Returns a list of matches.

Example:

{% if "foo" | regex_search("f") %}
    // do something
{% endif %}
regex_replace(pattern, replacement)

Does a regex-based replacement. Returns the result.

Example:

{{ "foo_bar" | regex_replace("[^A-Za-z_]", "-") }} -> "foo-bar"

Schema Processing Filters

schema_type(project_name, root, schema_format)

Returns the type name for a schema reference, considering the schema format and project context.

Used in: All language templates for generating typed method signatures and data class references.

Real example from C# Kafka producer:

{%- set data_type = message.dataschemauri | schema_type(data_project_name, root, message.dataschemaformat) | pascal %}
public async Task Send{{ message.messageid | pascal }}Async({{ data_type }} data)

Rationale: The schema_type filter resolves a schema URI reference (like /schemagroups/devices/schemas/temperature) to the actual generated class name (like TemperatureData), taking into account the data project name prefix and schema format.

Example:

{{ schema_ref | schema_type(projectname, root, "avro") }}

Stack and State Management Filters

push(stack_name)

Pushes a value onto a named stack. This works across template files. Returns an empty string.

Example:

{{ "value1" | push("mystack") }}
{{ "value2" | push("mystack") }}
pushfile(name)

Pushes a file path and value onto the 'files' stack for later processing. Returns an empty string.

Example:

{{ content | pushfile("output.txt") }}
save(prop_name)

Saves a value in the context dictionary for later retrieval. Returns the value for chaining.

Example:

{{ "important_value" | save("myvar") }}

Resource Tracking Filters

mark_handled

Marks a resource reference as handled by templates. Used for template-driven resource management.

Example:

{{ "#/schemas/myschema" | mark_handled }}
is_handled (Note: Not currently registered as a template filter)

Checks if a resource reference has been marked as handled.

Important: This filter is defined in code but not currently exposed to templates. Use mark_handled to track resources and check handling status in code.

Example (conceptual):

{% if "#/schemas/myschema" | is_handled %}
    // skip this resource
{% endif %}

Functions

You can use all expressions and functions defined by the Jinja2 standard library. In addition, the following functions are available as global functions in templates:

pop(stack_name)

Pops a value from a named stack collected with push. This works across template files. If the stack is empty, an empty string is returned.

Example:

{{ "foo" | push("name") }}
{{ "bar" | push("name") }}
{{ "baz" | push("name") }}
{{ pop("name") }} -> "baz"
{{ pop("name") }} -> "bar"
{{ pop("name") }} -> "foo"

stack(stack_name) (Note: Currently only available via ctx.stacks.stack() in code)

Gets the full contents of a named stack collected with push. This works across template files. Returns a list.

Important: This function is currently not exposed as a global in templates but exists in the ContextStacksManager. To iterate over a stack, use pop() in a loop or access via internal context.

Example (conceptual):

{{ "foo" | push("name") }}
{{ "bar" | push("name") }}
{{ "baz" | push("name") }}
{% for x in stack("name") %}{{ x }} {% endfor %} -> foo bar baz

get(prop_name) (Note: Currently only available via ctx.stacks.get() in code)

Gets the value of a named property collected with save. This works across template files.

Important: This function is currently not exposed as a global in templates but exists in the ContextStacksManager. Values saved with save filter can be retrieved in code but not directly in templates.

Example (conceptual):

{{ "foo" | save("name") }}
{{ get("name") }} -> "foo"

latest_dict_entry(dict)

Gets the "latest" entry from a dictionary, which is specifically designed to work for the versions property of a schema object. The latest entry is the one with the highest version number.

Example:

{%- set schemaObj = schema_object(root, event.schemaurl) -%}
{%- if schemaObj.versions is defined -%}
   {%- set schemaVersion = latest_dict_entry(schemaObj.versions) %}
{%- endif -%}

schema_object(root, schemaurl)

Gets an object by resolving a given relative URL within the root document. This is useful for getting the schema object for a given event or command.

Example:

{%- set schemaObj = schema_object(root, event.schemaurl) -%}
{%- if schemaObj.format is defined -%}
    // work with schemaObj
{%- endif -%}

URL Utility Functions

geturlhost(url)

Extracts the hostname from a URL.

Example:

{{ geturlhost("https://example.com:8080/path") }} -> example.com
geturlpath(url)

Extracts the path from a URL.

Example:

{{ geturlpath("https://example.com:8080/path/to/resource") }} -> /path/to/resource
geturlscheme(url)

Extracts the scheme (protocol) from a URL.

Example:

{{ geturlscheme("https://example.com/path") }} -> https
geturlport(url)

Extracts the port from a URL.

Example:

{{ geturlport("https://example.com:8080/path") }} -> 8080

dependency(language, runtime_version, dependency_name)

Retrieves dependency information for a specific language runtime from the central dependencies file. Returns the dependency configuration as a string (e.g., XML for Maven dependencies, PackageReference for NuGet).

Central dependency files are located at:

  • C#: xrcg/dependencies/cs/{runtime_version}/dependencies.csproj
  • Java: xrcg/dependencies/java/{runtime_version}/pom.xml
  • Python: xrcg/dependencies/python/{runtime_version}/pyproject.toml
  • TypeScript: xrcg/dependencies/typescript/{runtime_version}/package.json

Parameters:

  • language: The language identifier (cs, java, py, ts)
  • runtime_version: The runtime version identifier (e.g., net100, jdk21)
  • dependency_name: The dependency identifier (artifact ID for Java, package name for others)

Java: GroupId:ArtifactId Syntax

For Java dependencies, you can use groupId:artifactId syntax to disambiguate dependencies with the same artifact ID from different groups:

{{ dependency("java", "jdk21", "org.junit.jupiter:junit-jupiter") }}
{{ dependency("java", "jdk21", "org.testcontainers:junit-jupiter") }}

Example usage with a macro:

{# Define a convenience macro at the top of the template #}
{%- macro dep(name) -%}{{ dependency('java', 'jdk21', name) }}{%- endmacro -%}

{# Use the macro in the template #}
<dependencies>
    {{ dep('kafka-clients') }}
    {{ dep('cloudevents-core') }}
    {{ dep('org.testcontainers:testcontainers') }}
</dependencies>

C# Example:

{%- macro dep(name) -%}{{ dependency('cs', 'net100', name) }}{%- endmacro -%}

<ItemGroup>
    {{ dep('CloudNative.CloudEvents') }}
    {{ dep('Azure.Messaging.ServiceBus') }}
</ItemGroup>

Tags

You can use all tags defined by the Jinja2 standard library. In addition, the following custom tags are available:

{% exit %}

Exits the template without producing any output. This is useful for skipping the file if the input document doesn't contain the required information or if the template creates output files using pushfile and you don't want a file to be emitted for the template itself.

Example:

{% if root.type is not defined %}{% exit %}{% endif %}

{% time %}

Generates a timestamp and emits it into the output. The timestamp is in ISO 8601 format with microseconds.

Example:

{% time %} -> 2025-11-11T10:30:45.123456Z

{% error "message" %}

Raises a template error with a custom message. This stops template rendering and reports the error to the user. Useful for validation and enforcing template preconditions.

Example:

{% if not projectname %}
    {% error "projectname is required but not provided" %}
{% endif %}