Building Java–Rust Microservices with TeaQL: Models, Events, and Audit Intent
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.xmlfor the Java Merchant Service;payment_model.xmlfor the Rust Payment Service;merchant_dbandpayment_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 concept | Rust/Axum equivalent in the sample | Important difference |
|---|---|---|
| Spring-managed application service | Axum State<AppState> | State is assembled explicitly and must satisfy Rust's thread-safety requirements |
| Shared singleton reference | Arc<ServiceRuntime> | Arc shares ownership; it does not make an unsafe inner value thread-safe |
CompletableFuture or reactive pipeline | async fn plus .await on Tokio | An async function becomes a state machine and yields at .await; it does not reserve one thread per request |
ConcurrentHashMap / lock | tokio::sync::RwLock<HashSet<_>> | An async-aware lock avoids blocking a Tokio worker, but it is still process-local state |
BigDecimal | rust_decimal::Decimal | Payment amounts stay decimal instead of passing through binary floating point |
| Exceptions | Result<T, E> and ? | Every propagated failure is visible in the function's return type |
| Generated entity methods | Generated Rust traits and helpers | Snake-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:
| Concern | Reference implementation | Production requirement |
|---|---|---|
| Delivery | Immediate HTTP attempt plus scheduled retry | Define at-least-once delivery, backoff, timeouts, and operational ownership |
| Duplicate handling | Rust keeps processed event IDs in an in-memory HashSet | Persist an inbox/idempotency key with a unique constraint |
| Ordering | Not guaranteed | Add aggregate version or sequence checks if ordering matters |
| Event schema | Merchant state is assembled when dispatched | Persist an immutable payload and schema version in the outbox |
| Dead letters | Marked after three failures | Add alerting, inspection, replay, and reconciliation procedures |
| Projection drift | Manual inspection | Run 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:
- synchronized merchant
900001; - sent the same event again and received HTTP 200 without applying it twice;
- created order
ARTICLE-20260723-1for CNY 99.90; - observed status
CREATED; - applied a successful gateway callback;
- observed status
SUCCESS; - 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:
| Observation | Result |
|---|---|
Rust process start to /health, existing schema, 5 runs | 976–1,010 ms; median 980 ms |
| Rust resident memory at readiness | approximately 8.7 MiB |
| Rust application binary | 13,552,384 bytes |
| Rust container image | approximately 22.9 MB |
| Java existing-container start to Actuator health, 3 runs | 5.77–6.56 s; median 6.18 s |
| Spring-reported application startup in those runs | 4.64–5.22 s |
| Java container image | approximately 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.
