DEV Community

Cover image for Spring Boot + ASP.NET Core in the Same Architecture? Here Are 4 Patterns That Work
JNBridge
JNBridge

Posted on • Originally published at jnbridge.com

Spring Boot + ASP.NET Core in the Same Architecture? Here Are 4 Patterns That Work

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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)));
Enter fullscreen mode Exit fullscreen mode

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.Json handles this, but verify timezone handling.
  • Add Polly for retry and circuit breaker on the .NET side.
  • Spring Boot's @Valid errors return 400 with a different shape than ASP.NET Core's ProblemDetails. 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;
}
Enter fullscreen mode Exit fullscreen mode
// 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));
        });
    }
}
Enter fullscreen mode Exit fullscreen mode
// 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);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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>();
Enter fullscreen mode Exit fullscreen mode

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));
    }
}
Enter fullscreen mode Exit fullscreen mode
// 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!);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode
// 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"
        };
    });
Enter fullscreen mode Exit fullscreen mode

Distributed Tracing with OpenTelemetry

# Spring Boot: application.yml
otel:
  service:
    name: spring-product-service
  exporter:
    otlp:
      endpoint: http://jaeger:4317
Enter fullscreen mode Exit fullscreen mode
// 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")));
Enter fullscreen mode Exit fullscreen mode

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)