Skip to main content

Building Java–Rust Microservices with TeaQL: Models, Events, and Audit Intent

· 14 min read
TeaQL Team
Core Team

The Architecture We Actually Want

Java remains a strong choice for workflow-heavy services such as merchant onboarding, KYC, approvals, accounting, and integration with an established enterprise platform. Rust is attractive for customer-facing services where resource use, predictable latency, and fast process startup matter.

Using both languages does not mean that two services should share one database or one persistence model. That would weaken their bounded contexts. The goal is more precise:

  • each service owns its model, database, and deployment lifecycle;
  • reviewed metadata generates the domain API for the language used by that service;
  • both stacks follow the same query-intent and mutation-audit conventions;
  • communication happens through an explicit, versioned integration contract.

We built a runnable Java–Rust payment reference project to test that design instead of treating it as an architecture diagram.

What TeaQL Unifies—and What It Does Not

TeaQL uses reviewed XML/KSML metadata to generate typed domain objects, query builders, relationship helpers, status constants, and persistence APIs. The Java and Rust targets deliberately feel similar:

Merchant merchant = Q.merchants()
.withIdIs(id)
.comment("Find merchant for approval")
.purpose("Approve the reviewed KYC details")
.executeForOne(userContext);

merchant.updateStatusToActive();
merchant.auditAs("Approve merchant").save(userContext);
let orders = Q::payment_orders()
.with_id_is(payment_id)
.comment("Find the order receiving a gateway callback")
.purpose("Apply the verified payment result")
.execute_for_list(&ctx)
.await?;

order.update_status_to_success();
order
.audit_as("Apply successful gateway callback")
.save(&ctx)
.await?;

The shared engineering vocabulary is:

  • comment: what data operation is being performed;
  • purpose: why the application needs the read;
  • auditAs / audit_as: what business change is being persisted.

This makes generated and AI-assisted code easier to inspect, but intent text is not authorization. The application must still authenticate the actor, enforce tenant and object permissions, validate allowed state transitions, protect audit sinks, and test custom SQL or other bypass paths.

TeaQL also does not require every microservice to use one global model. The reference project intentionally contains:

  • merchant_model.xml for the Java Merchant Service;
  • payment_model.xml for the Rust Payment Service;
  • merchant_db and payment_db, following database-per-service ownership.

The models are separate because Merchant, MerchantKyc, OutboxEvent, CachedMerchant, and PaymentOrder belong to different bounded contexts. Consistency comes from an explicit synchronization contract, not from sharing persistence entities.

Five Engineering Controls for AI-Assisted Development

It is useful to describe TeaQL as five reinforcing controls rather than as an absolute security guarantee.

1. Reviewed metadata

The model records entity fields, relationships, decimal values, status vocabularies, and database mappings in one reviewable place for each bounded context. AI can propose a change, but the model and generated diff remain review gates.

2. Model evaluation

Evaluation catches supported structural errors before generation, including invalid or incomplete KSML and incompatible relationships. It reduces model mistakes; it does not prove that the business design is correct.

3. Generated typed APIs

Java POJOs and request builders, and Rust structs and generated helpers, turn many naming and type mismatches into compilation failures. Helpers such as update_status_to_success() avoid handwritten status identifiers. The application still owns transition rules such as whether CREATED -> SUCCESS is allowed for a particular callback.

4. Reproducible workspaces

Generation produces the language-specific project structure and manifests, including Maven or Cargo dependencies. Pinning the generator and runtime versions makes generated changes reviewable and repeatable.

5. Runtime intent and audit boundaries

Maintained generated paths require declared query intent and audited persistence. Runtime hooks provide places to apply request policy, tracing, and provider behavior. Handwritten I/O, identity, authorization, encryption, infrastructure hardening, and audit-log durability remain system responsibilities.

Together, these controls reduce model drift and make AI-generated changes easier to review. They do not eliminate hallucinations or replace security and compliance engineering.

The Tested Reference Flow

The sample uses a Java Merchant Service, a Rust Payment Service, PostgreSQL, and Consul:

Java Merchant Service
├─ writes Merchant + OutboxEvent in merchant_db
└─ discovers the Rust service through Consul


Rust Payment Service
├─ receives merchant state synchronization
├─ stores a local CachedMerchant in payment_db
└─ validates and processes customer-facing payments locally

Java: workflow and transactional outbox

Registering, approving, or suspending a merchant updates the merchant and creates an OutboxEvent under the same Spring transaction. Delivery is attempted asynchronously, while a scheduled worker polls pending and failed records every 30 seconds. After three failed attempts, the sample moves the record to a dead-letter state.

This is a transactional outbox reference implementation, not event sourcing and not an exactly-once transport. The current sample synchronizes the latest merchant state. A production event contract should normally persist an immutable, versioned payload in the outbox record itself.

Rust: local projection and payment execution

The Rust service receives the merchant synchronization request and stores a small local projection containing the merchant identifier, application key, and active state. Payment creation can then validate the merchant without a synchronous call back to Java.

The payment model generates typed helpers for channels and statuses, including:

record.update_channel_to_alipay();
order.update_status_to_created();
order.update_status_to_success();

The callback handler adds an application-owned guard: only orders in CREATED or PAYING may move to a final result. Generated helpers make the update explicit; the handler still defines the business rule.

Rust Concerns for a Java Team

Java engineers evaluating a Rust service usually have more practical questions than “does it have a garbage collector?” They need to understand how request state is shared, where concurrency is controlled, how failures cross the HTTP boundary, and whether the domain API still expresses business intent.

The following mapping is a useful starting point:

Familiar Java conceptRust/Axum equivalent in the sampleImportant difference
Spring-managed application serviceAxum State<AppState>State is assembled explicitly and must satisfy Rust's thread-safety requirements
Shared singleton referenceArc<ServiceRuntime>Arc shares ownership; it does not make an unsafe inner value thread-safe
CompletableFuture or reactive pipelineasync fn plus .await on TokioAn async function becomes a state machine and yields at .await; it does not reserve one thread per request
ConcurrentHashMap / locktokio::sync::RwLock<HashSet<_>>An async-aware lock avoids blocking a Tokio worker, but it is still process-local state
BigDecimalrust_decimal::DecimalPayment amounts stay decimal instead of passing through binary floating point
ExceptionsResult<T, E> and ?Every propagated failure is visible in the function's return type
Generated entity methodsGenerated Rust traits and helpersSnake-case APIs carry the same model vocabulary while following Rust conventions

Shared state: Arc is not a global variable

The Axum router clones AppState for request handling:

#[derive(Clone)]
struct AppState {
ctx: Arc<ServiceRuntime>,
processed_events: Arc<
tokio::sync::RwLock<std::collections::HashSet<String>>
>,
}

Cloning this value clones two Arc handles, not the runtime or the set. Arc provides atomic reference-counted ownership. Axum and the compiler still require the contained state to be safe to send and share across worker threads.

The synchronization handler deliberately releases the write lock before awaiting database work:

{
let mut events = state.processed_events.write().await;
if events.contains(&idempotency_key) {
return Ok(StatusCode::OK);
}
events.insert(idempotency_key.clone());
} // the guard is dropped here

// Database awaits happen after the lock has been released.

Holding a lock guard across network or database .await points can serialize unrelated requests or create hard-to-debug contention. There is also a subtler problem in this demonstration: the key is inserted before the database save. If the save fails, a retry in the same process can be incorrectly suppressed. A production consumer should insert a durable inbox key and update the projection in one database transaction.

Async Rust: explicit suspension, not automatic speed

An Axum handler is an async fn. Database calls return futures, and .await allows Tokio to run other work while the operation is pending. This is closer to a structured reactive pipeline than to a traditional thread-per-request servlet, but the control flow remains ordinary top-to-bottom Rust.

Here is the core of the payment-creation path:

async fn create_payment(
State(state): State<AppState>,
Json(payload): Json<CreateOrderDto>,
) -> Result<Json<CreateOrderVo>, (StatusCode, String)> {
let merchant_id = payload.merchant_id
.parse::<u64>()
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid merchant ID".into()))?;

let merchants = Q::cached_merchants()
.with_id_is(merchant_id)
.comment("Find the local merchant projection")
.purpose("Authorize payment creation without a Java round trip")
.execute_for_list(state.ctx.as_ref())
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

if merchants.is_empty() {
return Err((
StatusCode::BAD_REQUEST,
"Merchant not synchronized".into(),
));
}

if !merchants[0].is_active() {
return Err((StatusCode::FORBIDDEN, "Merchant is suspended".into()));
}

let mut order = Q::payment_orders()
.comment("Create a payment order")
.purpose("Start the authorized checkout")
.new_entity(state.ctx.as_ref());

order
.update_merchant_id(payload.merchant_id)
.update_merchant_order_no(payload.order_no)
.update_amount(payload.amount) // rust_decimal::Decimal
.update_currency(payload.currency)
.update_status_to_created();

let saved = order
.audit_as("Initiate payment order")
.save(state.ctx.as_ref())
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

let payment_id = saved.id()
.and_then(|value| value.try_u64())
.unwrap_or(0);

Ok(Json(CreateOrderVo {
payment_id,
pay_url: format!("https://gateway.example/pay/{payment_id}"),
}))
}

The snippet intentionally keeps the domain sequence visible: parse the identifier, load the local projection, apply authorization, create the order, and save it with audit intent. Async Rust does not make blocking work safe automatically. CPU-heavy work and blocking libraries still need isolation from Tokio's worker threads, and connection-pool limits still determine database concurrency.

Result makes the transport boundary visible

The sample returns Result<Json<_>, (StatusCode, String)>, which is compact for a demonstration. Production code should normally define an application error enum and one conversion into an HTTP response. That keeps database errors out of public response bodies and gives expected failures—invalid input, suspended merchant, missing projection, conflict—a stable machine-readable code.

Rust's ? operator is not an unchecked exception. It returns early with the declared error type, so reviewers can follow every failure path from a generated query or audited save to the HTTP boundary.

Typed helpers do not replace a state machine

Generated methods such as status_is_created() and update_status_to_success() prevent stringly typed field access. They do not by themselves prove that a transition is allowed. The callback handler still owns the rule:

if !order.status_is_created() && !order.status_is_paying() {
return Err((
StatusCode::CONFLICT,
"Payment order is already in a final state".into(),
));
}

This division is intentional: the model generates a stable vocabulary, while application code expresses the use-case-specific transition policy.

No GC does not mean “no latency”

Rust removes garbage-collection pauses and JIT warmup from this service, but latency still comes from allocation, locks, executor scheduling, logging, database pools, SQL plans, network calls, and the payment gateway. The borrow checker prevents many memory and data-race errors at compile time; it does not validate business rules or guarantee a particular P99.

That is why the evidence below reports measured readiness and labels loopback timings as functional observations rather than extrapolating them into Kubernetes scaling claims.

Delivery Semantics and Failure Handling

Cross-service consistency is where a polyglot design succeeds or fails. The reference project demonstrates retries and duplicate suppression, but production deployments should make the following choices explicit:

ConcernReference implementationProduction requirement
DeliveryImmediate HTTP attempt plus scheduled retryDefine at-least-once delivery, backoff, timeouts, and operational ownership
Duplicate handlingRust keeps processed event IDs in an in-memory HashSetPersist an inbox/idempotency key with a unique constraint
OrderingNot guaranteedAdd aggregate version or sequence checks if ordering matters
Event schemaMerchant state is assembled when dispatchedPersist an immutable payload and schema version in the outbox
Dead lettersMarked after three failuresAdd alerting, inspection, replay, and reconciliation procedures
Projection driftManual inspectionRun a reconciliation job and define stale-data behavior

The in-memory duplicate guard is useful for demonstrating the flow, but it is cleared by a Rust process restart. It must not be presented as durable idempotency.

For a payment system, also decide what happens when the local merchant projection is stale. Depending on the risk policy, the service may reject the request, apply a short validity window, or perform a controlled authoritative lookup. TeaQL provides typed data access for implementing that policy; it does not choose the policy.

Evidence from the Reference Project

We verified commit 819d4ef on July 23, 2026.

The test host used:

  • Ubuntu 22.04, Linux 6.8, x86-64;
  • Intel Core i7-10750H, 6 cores / 12 threads;
  • 62 GiB RAM;
  • PostgreSQL 15 Alpine and Consul 1.17;
  • Spring Boot 3.2.5 with TeaQL Java 1.525-RELEASE;
  • Axum 0.7 with TeaQL Rust 4.1.1;
  • Rust 1.96.0 for the checked binary.

One marked Rust-side integration test performed the following operations against a fresh payment_db:

  1. synchronized merchant 900001;
  2. sent the same event again and received HTTP 200 without applying it twice;
  3. created order ARTICLE-20260723-1 for CNY 99.90;
  4. observed status CREATED;
  5. applied a successful gateway callback;
  6. observed status SUCCESS;
  7. verified the order and trade number in PostgreSQL at version 2.

On the host loopback interface, the individual HTTP observations ranged from approximately 0.3 ms to 10 ms. These are functional observations from one run, not a throughput or tail-latency benchmark.

Startup and footprint observations

We also measured readiness rather than repeating unqualified “millisecond scaling” claims:

ObservationResult
Rust process start to /health, existing schema, 5 runs976–1,010 ms; median 980 ms
Rust resident memory at readinessapproximately 8.7 MiB
Rust application binary13,552,384 bytes
Rust container imageapproximately 22.9 MB
Java existing-container start to Actuator health, 3 runs5.77–6.56 s; median 6.18 s
Spring-reported application startup in those runs4.64–5.22 s
Java container imageapproximately 362 MB

The Rust measurement starts a host process; the Java measurement starts an existing Docker container. They are useful implementation observations, not an apples-to-apples framework benchmark. Neither measurement includes Kubernetes scheduling, image pulling, production TLS, external load balancers, or realistic request concurrency.

The results support a modest conclusion: in this sample, the Rust payment process has a materially smaller runtime and image footprint. They do not prove that rewriting a Java service in Rust will improve end-to-end payment latency, which may instead be dominated by the database, network, or external gateway.

Issues found during verification

Running the sample also exposed two follow-up items:

  • on the first Java boot, the scheduled outbox poll can run before schema initialization finishes and log a transient missing-table error;
  • Rust query executions in the checked commit declare .purpose(...) but should also add .comment(...) to follow the current intent contract shown above.

Neither issue invalidates the demonstrated Rust payment flow, but both should be fixed before describing the sample as production-ready. Publishing findings like these is part of the value of an executable reference project.

When This Architecture Fits

This design is a reasonable candidate when:

  • bounded contexts already have clear service and data ownership;
  • a customer-facing path has measured resource or latency constraints;
  • the team can operate both Java and Rust in production;
  • event delivery, replay, schema evolution, and reconciliation have named owners;
  • generated models and diffs are part of code review.

It is probably not worth the additional operational complexity when a Java service already meets its objectives, when the bottleneck is an external system, or when the organization cannot support two build, observability, security, and incident-response toolchains.

Conclusion

The useful promise of TeaQL in a Java–Rust architecture is not that one global model erases language and service boundaries. It is that each bounded context can generate a typed, reviewable domain API while both languages share explicit conventions for query intent, audited mutation, and model evolution.

The reference project demonstrates the complete path from Java merchant approval, through an outbox and service discovery, to a Rust local projection and payment-state update. Its limitations are equally important: durable idempotency, immutable event versions, ordering, reconciliation, authorization, and compliance controls remain production engineering work.

That combination—generated consistency with explicit boundaries—is a much stronger foundation for AI-assisted development than asking an AI to maintain two unrelated handwritten persistence layers.

Editorial note: This article was written by the TeaQL maintainers with AI-assisted editing. The architecture, source code, measurements, and conclusions were reviewed and reproduced by the maintainers.