Nobody sets out to run both Spring Boot and ASP.NET Core. But acquisitions happen, teams make different (valid) technology choices, and suddenly you're staring at two frameworks that need to talk to each other.
I've been working on Java/.NET integration for years, and these are the four patterns I keep coming back to — each with real code you can adapt.
Why These Frameworks End Up Together
Teams don't usually plan to run both frameworks. It happens because:
- Acquisitions: Company A (.NET shop) acquires Company B (Java shop). Now you have Spring Boot microservices talking to ASP.NET Core APIs.
- Best-of-breed choices: The data engineering team chose Spring Boot for Kafka integration. The frontend team chose ASP.NET Core for the API gateway. Both are valid.
- Legacy + Modern: A mature Spring Boot backend serves a new ASP.NET Core frontend built for a modern SPA.
- Vendor libraries: A critical third-party SDK only ships as a Java JAR. Your application is ASP.NET Core.
Pattern 1: REST API Integration
The most common pattern. Spring Boot exposes REST endpoints, ASP.NET Core consumes them (or vice versa).
Spring Boot Side (API Provider)
@RestController
@RequestMapping("/api/products")
public class ProductController {
@Autowired
private ProductService productService;
@GetMapping("/{id}")
public ResponseEntity<ProductDTO> getProduct(@PathVariable Long id) {
return productService.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@GetMapping
public Page<ProductDTO> searchProducts(
@RequestParam String query,
Pageable pageable) {
return productService.search(query, pageable);
}
}
ASP.NET Core Side (API Consumer)
public class ProductApiClient
{
private readonly HttpClient _http;
public ProductApiClient(HttpClient http)
{
_http = http;
}
public async Task<Product?> GetProductAsync(long id)
{
var response = await _http.GetAsync($"/api/products/{id}");
if (!response.IsSuccessStatusCode) return null;
return await response.Content.ReadFromJsonAsync<Product>();
}
public async Task<PagedResult<Product>> SearchAsync(
string query, int page = 0, int size = 20)
{
var response = await _http.GetFromJsonAsync<PagedResult<Product>>(
$"/api/products?query={query}&page={page}&size={size}");
return response!;
}
}
// Registration in Program.cs
builder.Services.AddHttpClient<ProductApiClient>(client =>
{
client.BaseAddress = new Uri("http://spring-boot-service:8080");
client.Timeout = TimeSpan.FromSeconds(10);
})
.AddTransientHttpErrorPolicy(p =>
p.WaitAndRetryAsync(3, attempt => TimeSpan.FromMilliseconds(200 * attempt)))
.AddTransientHttpErrorPolicy(p =>
p.CircuitBreakerAsync(5, TimeSpan.FromSeconds(30)));
Watch out for:
- Spring Boot's
Page<T>pagination uses a different JSON structure than what ASP.NET Core expects. Map accordingly. - Spring Boot returns dates as ISO-8601 by default (Jackson).
System.Text.Jsonhandles this, but verify timezone handling. - Add Polly for retry and circuit breaker on the .NET side.
- Spring Boot's
@Validerrors return 400 with a different shape than ASP.NET Core'sProblemDetails. Normalize your error handling.
Pattern 2: gRPC Integration
For higher-performance scenarios, gRPC gives you better throughput with strong typing via Protobuf:
syntax = "proto3";
package product;
service ProductService {
rpc GetProduct (ProductRequest) returns (ProductResponse);
rpc StreamPriceUpdates (PriceSubscription) returns (stream PriceUpdate);
}
message ProductRequest {
int64 id = 1;
}
message ProductResponse {
int64 id = 1;
string name = 2;
string description = 3;
double price = 4;
google.protobuf.Timestamp updated_at = 5;
}
// Spring Boot gRPC server
@GrpcService
public class ProductGrpcService extends ProductServiceGrpc.ProductServiceImplBase {
@Override
public void getProduct(ProductRequest request,
StreamObserver<ProductResponse> observer) {
var product = productService.findById(request.getId());
observer.onNext(toProto(product));
observer.onCompleted();
}
@Override
public void streamPriceUpdates(PriceSubscription request,
StreamObserver<PriceUpdate> observer) {
priceService.subscribe(request.getSymbol(), update -> {
observer.onNext(toPriceProto(update));
});
}
}
// ASP.NET Core gRPC client
public class ProductGrpcClient
{
private readonly ProductService.ProductServiceClient _client;
public async Task<Product> GetProductAsync(long id)
{
var response = await _client.GetProductAsync(
new ProductRequest { Id = id });
return Product.FromProto(response);
}
public async IAsyncEnumerable<PriceUpdate> StreamPricesAsync(
string symbol, [EnumeratorCancellation] CancellationToken ct = default)
{
using var stream = _client.StreamPriceUpdates(
new PriceSubscription { Symbol = symbol });
await foreach (var update in stream.ResponseStream.ReadAllAsync(ct))
{
yield return PriceUpdate.FromProto(update);
}
}
}
gRPC's server-streaming is particularly useful when Spring Boot pushes real-time data to ASP.NET Core consumers — prices, events, notifications.
Pattern 3: In-Process Bridge
When ASP.NET Core needs to use Java libraries directly — without running a separate Spring Boot service — an in-process bridge loads the JVM inside the .NET process:
public class JavaRuleEngineService : IRuleEngine
{
private readonly com.company.rules.RuleEngine _javaEngine;
public JavaRuleEngineService()
{
// Bridge loads the Java rule engine in-process
// No separate Spring Boot service needed
_javaEngine = new com.company.rules.RuleEngine();
_javaEngine.LoadRules("/rules/production.drl");
}
public RuleResult Evaluate(BusinessContext context)
{
var javaContext = ContextMapper.ToJava(context);
var result = _javaEngine.Evaluate(javaContext);
return RuleResult.FromJava(result);
}
}
// Register in ASP.NET Core DI
builder.Services.AddSingleton<IRuleEngine, JavaRuleEngineService>();
When to use this: When you need a Java library (Drools, Apache Tika, a proprietary Java SDK) but don't want to deploy and maintain a separate Spring Boot service just to expose it. Tools like JNBridgePro make this possible.
Pattern 4: Event-Driven Integration via Kafka
For async workflows, both frameworks communicate through Apache Kafka (or RabbitMQ, Azure Service Bus):
// Spring Boot producer
@Service
public class OrderEventPublisher {
@Autowired
private KafkaTemplate<String, OrderEvent> kafka;
public void publishOrderCreated(Order order) {
kafka.send("order-events", order.getId(),
new OrderCreatedEvent(order));
}
}
// ASP.NET Core consumer
public class OrderEventConsumer : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
using var consumer = new ConsumerBuilder<string, string>(_config)
.Build();
consumer.Subscribe("order-events");
while (!ct.IsCancellationRequested)
{
var result = consumer.Consume(ct);
var orderEvent = JsonSerializer.Deserialize<OrderEvent>(
result.Message.Value);
await ProcessOrderEvent(orderEvent!);
}
}
}
Schema compatibility tip: Use Confluent Schema Registry with Avro or Protobuf schemas. Both Spring Boot (spring-kafka) and .NET (Confluent.SchemaRegistry) support it, ensuring both sides agree on message formats.
Shared Cross-Cutting Concerns
Authentication: Shared JWT Tokens
Both frameworks can validate the same JWT tokens from the same identity provider:
// Spring Boot
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwkSetUri("https://auth.company.com/.well-known/jwks")))
.build();
}
}
// ASP.NET Core
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = "https://auth.company.com";
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = "https://auth.company.com",
ValidateAudience = true,
ValidAudience = "api"
};
});
Distributed Tracing with OpenTelemetry
# Spring Boot: application.yml
otel:
service:
name: spring-product-service
exporter:
otlp:
endpoint: http://jaeger:4317
// ASP.NET Core
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing
.SetResourceBuilder(ResourceBuilder.CreateDefault()
.AddService("dotnet-api-gateway"))
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddGrpcClientInstrumentation()
.AddOtlpExporter(o => o.Endpoint = new Uri("http://jaeger:4317")));
With both exporting to the same collector, you get unified traces showing requests flowing across framework boundaries.
Choosing the Right Pattern
| Scenario | Pattern | Why |
|---|---|---|
| Separate teams, separate deployments | REST or gRPC | Clear contracts, independent scaling |
| Real-time data streaming | gRPC server-streaming or Kafka | Efficient push model |
| Need Java library in .NET app | In-process bridge | No separate service to deploy |
| Async event-driven workflow | Kafka/RabbitMQ | Decoupled, resilient, scalable |
| High-frequency calls (>1K/sec) | gRPC or in-process bridge | REST too slow at volume |
| Migration in progress | In-process bridge | Temporary integration without throwaway APIs |
Framework Feature Mapping
For developers working across both, here's how the core concepts map:
| Concept | Spring Boot | ASP.NET Core |
|---|---|---|
| DI Container | Spring IoC / @Autowired | builder.Services / [FromServices] |
| Middleware | Filters / Interceptors | app.Use() pipeline |
| Configuration | application.yml / @Value | appsettings.json / IConfiguration |
| Health Checks | Actuator /health | MapHealthChecks() |
| Metrics | Micrometer | System.Diagnostics.Metrics |
| ORM | JPA / Hibernate | Entity Framework Core |
| Validation | Bean Validation / @valid | DataAnnotations / FluentValidation |
| Background Jobs | @Scheduled / Spring Batch | BackgroundService / Hangfire |
| API Docs | SpringDoc / Swagger | Swashbuckle / NSwag |
Wrapping Up
Spring Boot and ASP.NET Core are more alike than different. When they need to cooperate, the right pattern depends on your coupling requirements, performance needs, and team structure. REST handles most cases. gRPC wins on performance-sensitive paths. Kafka decouples event-driven flows. And an in-process bridge gives you direct library access without the operational overhead of another service.
The real power move? Use multiple patterns in the same system, each where it fits best.
Originally published at jnbridge.com
Top comments (0)