Skip to content

Events from Rust, Go, Java and .NET

document:docs/events/other-languages

Write the schemas from a test with each language's own generator, commit them, and scan them like any JSON Schema.

View as Markdown

autocatalog reads contract files, so a service in any language joins the catalog once its schemas are on disk. Rust, Go, Java and .NET each have a generator that writes JSON Schema from a type and OpenAPI from routes. Call them from a test that writes contracts/, and commit what it writes:

contracts/
openapi.json the HTTP API, for the site's API reference
events/payment.completed.json one JSON Schema per event

x-autocatalog names the event: id, version, domain, producers and consumers, as in JSON Schema. Where the generator can’t add it easily, put the same block in a sidecar file beside the schema, payment.completed.autocatalog.json.

CI runs the test and fails if it changed anything, so the committed files always match the code:

- run: cargo test --test contracts # or go test, ./gradlew test, dotnet test
- run: git diff --exit-code contracts/
- run: npx autocatalog drift --base-ref origin/main --root .

examples/frameworks/ has the same payments service in all four languages. CI checks that each one produces the same catalog.

schemars for events, and openapi-axum or utoipa for the API.

#[derive(Serialize, JsonSchema)]
#[schemars(extend("x-autocatalog" = {
"id": "payment.completed",
"version": "1.0.0",
"domain": "payments",
"producers": ["PaymentService"],
"consumers": ["LedgerService"]
}))]
#[serde(rename_all = "camelCase")]
pub struct PaymentCompleted {
pub payment_id: String,
pub amount: f64,
pub currency: String,
}
// tests/contracts.rs
#[test]
fn contracts() {
let (_, openapi) = payments::app().split();
write("contracts/openapi.json", &openapi);
write("contracts/events/payment.completed.json", &schemars::schema_for!(PaymentCompleted));
}

invopop/jsonschema for events, and huma for the API. Fields without omitempty are required.

func (PaymentCompleted) JSONSchemaExtend(s *jsonschema.Schema) {
s.Extras = map[string]any{"x-autocatalog": map[string]any{
"id": "payment.completed", "version": "1.0.0", "domain": "payments",
"producers": []string{"PaymentService"}, "consumers": []string{"LedgerService"},
}}
}
func TestContracts(t *testing.T) {
write(t, "contracts/openapi.json", New(http.NewServeMux()).OpenAPI())
r := jsonschema.Reflector{DoNotReference: true}
write(t, "contracts/events/payment.completed.json", r.Reflect(&PaymentCompleted{}))
}

victools/jsonschema-generator for events, and springdoc for a Spring Boot API. The test starts the app and saves /v3/api-docs. Pin servers with @OpenAPIDefinition, or the random test port ends up in the file. Declare contracts/ as an output of the test task, tasks.test { outputs.dir("contracts") }, or Gradle skips the test as up to date and a stale file passes. The event metadata lives in a sidecar.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ContractsTest {
@Value("${local.server.port}") int port;
@Test
void contracts() throws Exception {
write("contracts/openapi.json", get("http://localhost:" + port + "/v3/api-docs"));
var config = new SchemaGeneratorConfigBuilder(SchemaVersion.DRAFT_2020_12, OptionPreset.PLAIN_JSON);
config.forFields().withRequiredCheck(field -> true); // every record component
var schema = new SchemaGenerator(config.build()).generateSchema(PaymentCompleted.class);
write("contracts/events/payment.completed.json", schema.toPrettyString());
}
}
contracts/events/payment.completed.autocatalog.json
{
"id": "payment.completed",
"version": "1.0.0",
"domain": "payments",
"producers": ["PaymentService"],
"consumers": ["LedgerService"]
}

JsonSchemaExporter from System.Text.Json for events, and Microsoft.AspNetCore.OpenApi for a minimal API. Neither needs a third-party package. Turn on RespectNullableAnnotations and strict number handling, or the web defaults widen every number to ["string", "number"].

static readonly JsonSerializerOptions Web = new(JsonSerializerDefaults.Web)
{
RespectNullableAnnotations = true,
NumberHandling = JsonNumberHandling.Strict,
TypeInfoResolver = new DefaultJsonTypeInfoResolver(),
};
[Fact]
public async Task Contracts()
{
using var factory = new WebApplicationFactory<Program>();
Write("contracts/openapi.json", JsonNode.Parse(await factory.CreateClient().GetStringAsync("/openapi/v1.json"))!);
var schema = Web.GetJsonSchemaAsNode(typeof(PaymentCompleted), new() { TreatNullObliviousAsNonNullable = true });
schema["$schema"] = "https://json-schema.org/draft/2020-12/schema";
schema["x-autocatalog"] = new JsonObject { ["id"] = "payment.completed", ["version"] = "1.0.0", /* … */ };
Write("contracts/events/payment.completed.json", schema);
}

The scanner skips target/, bin/, obj/ and build/. A schema copied into build output isn’t read twice.

Owned by MaintainersLast changed 3 Nov 2026SourceEdit this page