Modern applications rarely depend on a single programming language. A development team might use C# for its main application, Java for an existing business service, and Rust for a component that requires predictable resource usage or low-level control.
The challenge is coordinating these services without building a complicated deployment process for every language. Each service has its own build tools, runtime requirements, configuration, and health-check mechanisms.
.NET Aspire helps simplify this process by providing an application model for defining services, configuring dependencies, managing local development resources, and observing application behavior. Although Aspire is closely associated with .NET applications, its orchestration model can also support external processes and containerized services written in other languages.
In this article, we will build a small multi-language application in which a .NET Aspire application coordinates a Java service and a Rust service. We will explore project structure, service configuration, service discovery, health checks, and production considerations.
Understanding .NET Aspire and Multi-Language Applications
.NET Aspire provides tools for defining and developing distributed applications. An Aspire AppHost describes the application's resources and their relationships, while service projects implement the actual application functionality.
For a multi-language application, the AppHost can coordinate components that do not use .NET.
For example, consider an order-processing platform with three services:
Service | Technology | Responsibility |
|---|
Order API | ASP.NET Core | Accepts requests and coordinates processing |
Pricing service | Java | Calculates product prices |
Inventory service | Rust | Checks product availability |
The Java service might use an existing Spring Boot application, while the Rust service could expose an HTTP API using a framework such as Axum.
Aspire can help developers run these components together during local development. It can also provide service discovery and resource information where the relevant integration supports those capabilities.
However, Aspire is not a replacement for every production orchestration platform. Its AppHost defines and coordinates application resources, but production deployment still requires a suitable hosting strategy, security configuration, networking, monitoring, and operational controls.
Create the Aspire Application
Start by creating an Aspire solution using a supported .NET SDK and Aspire version.
For example, the following commands create a new solution and an Aspire AppHost:
dotnet new aspire-starter -n MultiLanguageApp
cd MultiLanguageApp
The exact templates and project structure depend on the installed SDK and Aspire version. If the template is unavailable, install or select the appropriate supported Aspire tooling.
A typical solution might contain the following structure:
MultiLanguageApp/
├── MultiLanguageApp.AppHost/
│ └── Program.cs
├── MultiLanguageApp.Api/
│ ├── Program.cs
│ └── MultiLanguageApp.Api.csproj
├── java-pricing/
│ ├── pom.xml
│ ├── Dockerfile
│ └── src/
└── rust-inventory/
├── Cargo.toml
├── Dockerfile
└── src/
The AppHost coordinates the resources. The ASP.NET Core project provides the main API, while the Java and Rust directories contain independently built services.
You do not need to convert the Java and Rust projects into .NET projects. Each service can continue using its native build system.
Build the Java Service
For this example, the Java service exposes an HTTP endpoint that returns a calculated price.
A Spring Boot controller could look like this:
package com.example.pricing;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class PricingController {
@GetMapping("/price")
public PriceResponse getPrice(
@RequestParam double amount) {
if (!Double.isFinite(amount) || amount < 0) {
throw new IllegalArgumentException(
"Amount must be a valid non-negative number");
}
double total = amount * 1.10;
return new PriceResponse(amount, total);
}
public record PriceResponse(
double amount,
double total) {
}
}
This example applies a 10 percent markup to the supplied amount. It demonstrates service communication rather than a production tax or pricing implementation.
Real pricing systems should use appropriate monetary representations, currency rules, validation, and business logic.
The application should also expose a health endpoint, such as /health, so that the orchestration environment can check whether the service is responding.
Configure the Java application to listen on a predictable port. For a containerized service, an environment variable can make the port configurable:
server.port=${SERVER_PORT:8080}
This configuration uses port 8080 by default while allowing the deployment environment to override it.
Build the Java application with its normal build system, such as Maven:
mvn clean package
The resulting executable JAR can then be packaged into a container image or launched as an external process.
Build the Rust Service
The Rust service will expose an endpoint that checks whether a product is available.
Create a Rust project using Cargo:
cargo new rust-inventory
cd rust-inventory
For an HTTP service, you can use a framework such as Axum with Tokio.
Add the required dependencies to Cargo.toml:
[package]
name = "rust-inventory"
version = "0.1.0"
edition = "2024"
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
These dependency declarations illustrate a basic setup. In a production application, maintain the resolved dependency versions in Cargo.lock and update them through a reviewed process.
Add the following code to src/main.rs:
use axum::{
extract::Query,
routing::get,
Json,
Router,
};
use serde::{Deserialize, Serialize};
use std::net::SocketAddr;
#[derive(Deserialize)]
struct InventoryQuery {
product_id: String,
}
#[derive(Serialize)]
struct InventoryResponse {
product_id: String,
available: bool,
}
async fn check_inventory(
Query(query): Query<InventoryQuery>,
) -> Json<InventoryResponse> {
let available = !query.product_id.trim().is_empty();
Json(InventoryResponse {
product_id: query.product_id,
available,
})
}
async fn health() -> &'static str {
"healthy"
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/inventory", get(check_inventory))
.route("/health", get(health));
let address = SocketAddr::from(([0, 0, 0, 0], 8081));
let listener = tokio::net::TcpListener::bind(address)
.await
.expect("Failed to bind inventory service");
axum::serve(listener, app)
.await
.expect("Inventory service failed");
}
This example provides a simple HTTP endpoint and a health endpoint.
The availability result is intentionally simplified. A real inventory service should query an authoritative inventory store instead of deriving availability from the request.
Binding to 0.0.0.0 allows the process to accept connections through its container network interface. In production, network policies and container exposure should restrict which systems can access the service.
Build and run the Rust application:
cargo build --release
cargo run --release
The application listens on port 8081.
Containerize the Java and Rust Services
Containerization provides a consistent way to package services written in different languages.
The following Dockerfile illustrates how to package the Java service:
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY target/pricing-service.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
The JAR filename must match the artifact generated by the Java build.
For Rust, a multi-stage Dockerfile can compile the application in one stage and run the resulting binary in another.
FROM rust:1 AS builder
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
COPY src ./src
RUN cargo build --release
FROM debian:bookworm-slim
WORKDIR /app
COPY --from=builder /app/target/release/rust-inventory /app/rust-inventory
EXPOSE 8081
CMD ["/app/rust-inventory"]
This example assumes that Cargo.lock exists and that the binary is compatible with the runtime image. Depending on the build environment and linked libraries, additional runtime packages may be necessary.
For production, pin base images to reviewed versions or digests, scan the images for vulnerabilities, and rebuild them when security updates are required.
Add the Services to the Aspire AppHost
The AppHost is where you define the application resources and how they are connected.
One straightforward approach is to run the Java and Rust services as containers. The following example illustrates the resource declarations:
var builder = DistributedApplication.CreateBuilder(args);
var pricing = builder
.AddContainer("pricing", "pricing-service:latest")
.WithHttpEndpoint(
targetPort: 8080,
name: "http");
var inventory = builder
.AddContainer("inventory", "inventory-service:latest")
.WithHttpEndpoint(
targetPort: 8081,
name: "http");
var api = builder
.AddProject<Projects.MultiLanguageApp_Api>("api")
.WithReference(pricing)
.WithReference(inventory);
builder.Build().Run();
The example demonstrates the resource model, but the exact API signatures and generated project identifiers depend on the Aspire version and project configuration. Confirm that the container integration and endpoint configuration match your installed version.
Build the Java and Rust images before starting the AppHost, or configure your development workflow to build them automatically.
The .WithReference() calls declare relationships between the API and the external services. With supported service-discovery integrations, the API can resolve the referenced resources through configuration provided by Aspire.
These references do not automatically make every HTTP client understand the service names. Your application must use the corresponding service-discovery configuration and HTTP client integration.
For containerized resources, distinguish the container's internal listening port from the host port used to access the service from your development machine.
Call the Services from ASP.NET Core
The ASP.NET Core API can use HTTP clients to communicate with the Java and Rust services.
For example, register an HTTP client for the pricing service:
builder.Services.AddHttpClient("pricing", client =>
{
client.Timeout = TimeSpan.FromSeconds(5);
});
This creates a named HTTP client with a five-second timeout. The timeout prevents the calling operation from waiting indefinitely for a response.
In the application code, resolve the client through IHttpClientFactory:
public sealed class PricingClient
{
private readonly IHttpClientFactory _factory;
public PricingClient(IHttpClientFactory factory)
{
_factory = factory;
}
public async Task<string> GetPriceAsync(
decimal amount,
CancellationToken cancellationToken)
{
var client = _factory.CreateClient("pricing");
var response = await client.GetAsync(
$"price?amount={amount}",
cancellationToken);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync(
cancellationToken);
}
}
This illustrates the HTTP call, but the example assumes that the client has a correctly configured BaseAddress. Configure that address using the service-discovery mechanism supported by your Aspire setup, rather than hard-coding a temporary development URL.
For a real application, use a typed response model instead of returning raw JSON text. Also validate the amount, encode query parameters correctly, and handle non-success responses with meaningful application errors.
For monetary values, consider using decimal in the .NET application and an appropriate representation in the Java service to avoid unintended floating-point rounding.
Health Checks and Observability
A distributed application needs to distinguish between a running process and a service that is actually ready to handle requests.
The Java and Rust services should expose health endpoints that return a successful response when they are ready.
For example:
Java service: /health
Rust service: /health
Configure health checks through the appropriate Aspire resource APIs or your container platform's health-check configuration.
A simple endpoint check can establish whether the HTTP service responds. More advanced checks may verify access to required databases or other dependencies.
Avoid making readiness checks so complex that a temporary downstream problem causes unnecessary restart loops.
Aspire's development dashboard can help developers inspect resources, logs, and telemetry. In production, use the observability and monitoring systems appropriate to your deployment platform.
Include correlation identifiers in requests and logs so that an operation can be followed across the .NET, Java, and Rust services.
Best Practices for Production
Keep Service Contracts Stable
Define clear HTTP contracts, request schemas, response schemas, and error formats. Version APIs when incompatible changes are required.
Configure Timeouts and Retries Carefully
Set reasonable timeouts for outbound requests. Retry only operations that are safe to repeat, and use bounded retries with backoff where appropriate.
Secure Internal Communication
Do not assume that services are secure merely because they communicate over a private network. Apply authentication and authorization where required, restrict network access, and protect secrets.
Make Builds Reproducible
Keep Java dependency metadata, Cargo.lock, .NET dependency configuration, and container build definitions under version control.
Monitor Each Service Independently
Track request latency, error rates, resource usage, and health status for every service. A healthy AppHost does not guarantee that every business operation is succeeding.
Common Mistakes to Avoid
One common mistake is assuming that Aspire can automatically manage every Java or Rust dependency. Each service still needs its own build configuration and dependency management.
Another mistake is hard-coding localhost addresses. Inside a container, localhost refers to that container itself, not another service. Use the correct service-discovery mechanism or configured network address.
Teams may also confuse resource startup with readiness. A container can start before its HTTP endpoint is ready, so callers should handle temporary connection failures appropriately.
Finally, avoid exposing internal service ports unnecessarily. Configure network access according to the actual communication requirements.
Advantages and Limitations
Using Aspire to coordinate Java, Rust, and .NET services provides a unified development workflow, centralized resource definitions, and easier local debugging.
It also allows teams to keep existing services in their original languages instead of rewriting them solely to fit a .NET-based application.
However, each language still requires its own toolchain, dependency management, security updates, and operational knowledge. Service discovery and telemetry also require compatible configuration across the different components.
For production deployment, select a hosting platform that supports your requirements for scaling, networking, secrets, availability, and recovery.
Summary
.NET Aspire can help coordinate a multi-language application that combines .NET, Java, and Rust services.
The key is to treat each service as an independent component with a defined interface, while using the Aspire AppHost to organize local development and supported service integrations.
Start by building and containerizing the Java and Rust services. Register them as resources in the AppHost, configure service discovery, and connect to them through properly configured HTTP clients.
Finally, implement health checks, secure internal communication, manage timeouts, and monitor each service independently. This approach makes a multi-language application easier to develop and troubleshoot without tying every component to the same programming language.
Join the conversation! Your thoughts help the community grow.