This project scaffolds a custom Apollo Router binary named starstuff with a native Rust plugin that maps GraphQL mutations to KurrentDB events. The schema in the supergraph-schema.graphql is made according to the target domain eventschemas in the target-domain-schemas folder.
- Rust toolchain 1.90.0 or newer (the repository targets
apollo-routerv2.6.2)
src/plugins/kurrent_mapper/mapper.rs– defines theMutationSinktrait (with the productionKurrentServiceimplementation) and handles persistence.src/plugins/mutation_plugin.rs– the plugin that detects mutations, logs them, and delegates persistence through aMutationSinkdependency.router.yaml– enables the plugin and provides its configuration.supergraph-schema.graphql– schema made according to schemas in the target-domain-schemas folder.
docker compose upThe first build downloads a large dependency set for Apollo Router; allow several minutes to complete.
- Start the server via
docker compose up - Navigate to "https://studio.apollographql.com/sandbox/explorer" and enter "http://localhost:4000" for the sandbox URL.
- Run a mutation like:
mutation RecordAutomatedSummary($input: AutomatedSummaryInput!, $metadata: EventMetadataInput!) {
recordAutomatedSummary(input: $input, metadata: $metadata) {
CreditScoreSummary
IncomeAndEmploymentSummary
LoanToIncomeSummary
metadata {
causationId
correlationId
}
}
}with inputs:
{
"input": {
"CreditScoreSummary": "3rd summary check",
"IncomeAndEmploymentSummary": "income syummary",
"LoanToIncomeSummary": "loan income",
"MaritalStatusAndDependentsSummary": "married with kids",
"RecommendedFurtherInvestigation": "no further invstigation",
"SummarizedAt": "1758982243380",
"SummarizedBy": "Billy"
"loanId": "123"
}
}- navigate to
http://localhost:2113/web/index.html#/streamsand you should see the event in thegraphql-mutation-recordAutomatedSummarystream. - verify the event shows up in the list of events in that stream page.
- The supergraph schema mirrors the JSON definitions under target-domain-schemas/. Every mutation field (e.g. recordLoanRequested) exposes an input whose shape matches the corresponding domain event payload. Schema validation guarantees clients can only send values that satisfy those field requirements.
- When a mutation reaches the router, extract_mutations walks the parsed operation and resolves every argument (including nested objects and variables) into real JSON . The resulting MutationCall contains exactly the argument object the client supplied—so the input JSON still has the same structure as the domain event schema, and the metadata argument mirrors Metadata.schema.json.
- The plugin hands those MutationCall values to an object that implements the
MutationSinktrait (the productionKurrentService). For each call we:- choose a stream named graphql-mutation- so every domain event type gets its own stream
- emit an event type like GraphQL.RecordLoanRequested, keeping the schema name recognizable
- serialize the entire call to JSON via EventData::json (so the stored body contains the input object and metadata exactly as GraphQL validated them)
- append it to KurrentDB over gRPC and log the stream, type, and new UUID .
- In production,
MutationInterceptorconstructs aKurrentServicewhich implements theMutationSinktrait. The service owns the real KurrentDB client, spawns an async task, and writes events to the correspondinggraphql-mutation-*stream. - In tests we swap the dependency for a lightweight mock that records the
MutationCallbatches. This lets us prove that:- only mutation operations trigger persistence, and
- the serialized payload presented to the sink matches the GraphQL input (already validated against the target domain schemas).
- The trait-based injection keeps the runtime logic untouched while making the plugin easy to exercise with
cargo test.
Given this mutation:
mutation CreateOrder{
createOrder(input: {...}) { ← Level 1: Captured as MutationCall
order { ← Level 2: "order" goes into selected_fields
id ← Level 3: NOT captured
customer { ← Level 3: NOT captured
name ← Level 4: NOT captured
email ← Level 4: NOT captured
}
}
success ← Level 2: "success" goes into selected_fields
}
}operation_name: "CreateOrder"
field_name: "createOrder"
arguments: the input arg
selected_fields: ["order", "success"] ← Only these two strings!
That order has nested fields like id and customer
That customer has name and email
Modify router.yaml to tweak the plugin configuration or add additional plugins.