Back to Blog

Spring Boot + gRPC Best Practices: The Communication Backbone of the Control Layer

In the Ontology-driven intelligent decision platform, the Control Layer uses Spring Boot 3.x as its application framework and gRPC as the internal inter-service communication protocol. This article deep dives into Protobuf message design, gRPC service definitions, Spring Boot integration, interceptor chains (authentication, logging, metrics, rate limiting), error handling and status code mapping, client-side load balancing, health checking, and progressive migration strategies from REST to gRPC. Based on production practices, we build a comprehensive Spring Boot + gRPC best-practices framework.

CoomiaPublished on November 21, 202513 min read
Share this articleTwitter / X

Series: S8 Technology Deep Dives · Article 13 | Level: Advanced | Reading Time: 20 min

Spring Boot + gRPC Best Practices: The Communication Backbone of the Control Layer

#TL;DR

In the Ontology-driven intelligent decision platform, the Control Layer uses Spring Boot 3.x as its application framework and gRPC as the internal inter-service communication protocol. This article deep dives into Protobuf message design, gRPC service definitions, Spring Boot integration, interceptor chains (authentication, logging, metrics, rate limiting), error handling and status code mapping, client-side load balancing, health checking, and progressive migration strategies from REST to gRPC. Based on production practices, we build a comprehensive Spring Boot + gRPC best-practices framework.

#1. Introduction: Why the Control Layer Chose gRPC

#1.1 REST vs gRPC: The Decision Criteria

During the Ontology platform's architecture design phase, we conducted a thorough evaluation of internal communication protocols. While REST/JSON is widely used, it has clear shortcomings in internal microservice communication scenarios.

Performance gap: gRPC uses Protocol Buffers as its serialization format. Binary encoding is 3-10x smaller than JSON, and serialization/deserialization is 5-10x faster. When the Control Layer processes thousands of Schema queries per second, this gap directly impacts service latency and throughput.

Strong-typed contracts: Protobuf's IDL (Interface Definition Language) enforces type checking at compile time. During cross-team collaboration, interface changes immediately manifest as compilation errors rather than runtime exceptions. In a multi-Layer collaborative platform, this "compile-time safety" is invaluable.

Streaming support: gRPC natively supports four communication patterns — unary calls, server streaming, client streaming, and bidirectional streaming. Control Layer scenarios like Schema change notifications and batch data synchronization are naturally suited for streaming communication.

Code generation: The Protobuf compiler automatically generates client and server code for Java, Python, TypeScript, and other languages. Communication code between Control Layer (Java), Reasoning & Decision Layer (Python), and SDK & Developer Experience Layer (TypeScript) is fully auto-generated, eliminating the effort of manually maintaining DTOs.

#1.2 Technology Stack Selection

The Control Layer technology stack:

  • Spring Boot 3.x: Application framework providing dependency injection, configuration management, monitoring integration
  • grpc-spring-boot-starter: Integration layer between Spring Boot and gRPC
  • Protobuf 3: Message definition and serialization
  • gRPC-Java: Java implementation of gRPC
  • Gradle 8.x: Build tool with Protobuf compilation plugin integration

#2. Protobuf Message Design

#2.1 Message Structure Design Principles

Protobuf message design follows the principle of "forward compatibility and backward compatibility." We established a message design specification:

PROTOBUF
syntax = "proto3";

package com.onto.control.v1;

option java_multiple_files = true;
option java_package = "com.onto.control.v1";
option java_outer_classname = "SchemaProto";

import "google/protobuf/timestamp.proto";
import "google/protobuf/struct.proto";
import "google/protobuf/wrappers.proto";

message ObjectType {
    string rid = 1;
    string api_name = 2;
    string display_name = 3;
    string description = 4;
    int32 version = 5;
    ObjectTypeStatus status = 6;
    string namespace_rid = 7;
    string primary_key_property_rid = 8;
    string title_property_rid = 9;
    google.protobuf.Struct schema_definition = 10;
    google.protobuf.Struct ui_config = 11;
    google.protobuf.Timestamp created_at = 12;
    google.protobuf.Timestamp updated_at = 13;
    string created_by = 14;
    repeated PropertyType properties = 15;
    repeated LinkType outgoing_links = 16;
}

enum ObjectTypeStatus {
    OBJECT_TYPE_STATUS_UNSPECIFIED = 0;
    OBJECT_TYPE_STATUS_DRAFT = 1;
    OBJECT_TYPE_STATUS_ACTIVE = 2;
    OBJECT_TYPE_STATUS_DEPRECATED = 3;
}

message PropertyType {
    string rid = 1;
    string api_name = 2;
    string display_name = 3;
    PropertyDataType data_type = 4;
    string description = 5;
    bool is_required = 6;
    bool is_indexed = 7;
    bool is_unique = 8;
    google.protobuf.Value default_value = 9;
    repeated Constraint constraints = 10;
}

#2.2 Field Number Strategy

Field numbers are key to Protobuf compatibility. We established a field number allocation specification:

  • 1-15: High-frequency fields (1-byte encoding)
  • 16-2047: Regular fields (2-byte encoding)
  • 2048-9999: Reserved extension fields
  • 10000+: Internal/debug fields

Allocated field numbers must never be reused. When fields are deprecated, use the reserved keyword:

PROTOBUF
message ObjectType {
    reserved 20, 21;
    reserved "legacy_field", "deprecated_config";
}

#2.3 Package Version Management

We maintain version numbers for each Protobuf package (e.g., v1, v2), creating new versions for incompatible changes:

Code
proto/
├── com/onto/control/v1/
│   ├── schema.proto
│   ├── action.proto
│   └── service.proto
├── com/onto/control/v2/
│   ├── schema.proto
│   └── service.proto
└── com/onto/common/v1/
    ├── pagination.proto
    └── errors.proto

Common messages (pagination, errors, etc.) reside in the common package to avoid cross-package duplication.

#3. gRPC Service Definition

#3.1 Service Interface Design

PROTOBUF
service SchemaRegistryService {
    // Unary calls
    rpc GetObjectType(GetObjectTypeRequest) returns (ObjectType);
    rpc CreateObjectType(CreateObjectTypeRequest) returns (ObjectType);
    rpc UpdateObjectType(UpdateObjectTypeRequest) returns (ObjectType);
    rpc DeleteObjectType(DeleteObjectTypeRequest) returns (google.protobuf.Empty);

    // List query with pagination
    rpc ListObjectTypes(ListObjectTypesRequest) returns (ListObjectTypesResponse);

    // Search
    rpc SearchObjectTypes(SearchObjectTypesRequest) returns (SearchObjectTypesResponse);

    // Server streaming: Schema change events
    rpc WatchSchemaChanges(WatchSchemaChangesRequest) returns (stream SchemaChangeEvent);

    // Batch operations
    rpc BatchGetObjectTypes(BatchGetObjectTypesRequest) returns (BatchGetObjectTypesResponse);
}

message GetObjectTypeRequest {
    string rid = 1;
    google.protobuf.Int32Value version = 2;
}

message ListObjectTypesRequest {
    string namespace_rid = 1;
    ObjectTypeStatus status_filter = 2;
    int32 page_size = 3;
    string page_token = 4;
    string order_by = 5;
}

message ListObjectTypesResponse {
    repeated ObjectType object_types = 1;
    string next_page_token = 2;
    int32 total_count = 3;
}

message SchemaChangeEvent {
    string rid = 1;
    ChangeType change_type = 2;
    ObjectType before = 3;
    ObjectType after = 4;
    google.protobuf.Timestamp changed_at = 5;
    string changed_by = 6;
}

#3.2 Request/Response Design Standards

We follow these conventions:

  • Each RPC method uses independent request and response messages (even if fields are identical)
  • List APIs uniformly use page_size + page_token pagination
  • Optional parameters use google.protobuf.wrappers wrapper types
  • Batch operations limit maximum entries (e.g., 100 items)

#3.3 Error Propagation Design

gRPC uses Status and StatusCode for error propagation. We define platform-specific error details:

PROTOBUF
message ErrorDetail {
    string error_code = 1;
    string message = 2;
    string target = 3;
    repeated ErrorDetail details = 4;
    map<string, string> metadata = 5;
}

#4. Spring Boot Integration

#4.1 Gradle Build Configuration

Kotlin
// build.gradle.kts
plugins {
    id("org.springframework.boot") version "3.2.5"
    id("io.spring.dependency-management") version "1.1.4"
    id("com.google.protobuf") version "0.9.4"
    java
}

dependencies {
    implementation("org.springframework.boot:spring-boot-starter")
    implementation("net.devh:grpc-spring-boot-starter:3.1.0.RELEASE")
    implementation("io.grpc:grpc-protobuf:1.62.2")
    implementation("io.grpc:grpc-stub:1.62.2")
    implementation("com.google.protobuf:protobuf-java:3.25.3")
    implementation("com.google.protobuf:protobuf-java-util:3.25.3")

    testImplementation("io.grpc:grpc-testing:1.62.2")
    testImplementation("org.springframework.boot:spring-boot-starter-test")
}

protobuf {
    protoc {
        artifact = "com.google.protobuf:protoc:3.25.3"
    }
    plugins {
        id("grpc") {
            artifact = "io.grpc:protoc-gen-grpc-java:1.62.2"
        }
    }
    generateProtoTasks {
        all().forEach { task ->
            task.plugins {
                id("grpc")
            }
        }
    }
}

#4.2 Server-Side Implementation

Java
@GrpcService
public class SchemaRegistryServiceImpl
        extends SchemaRegistryServiceGrpc.SchemaRegistryServiceImplBase {

    private final SchemaService schemaService;
    private final ObjectTypeMapper mapper;

    @Override
    public void getObjectType(
            GetObjectTypeRequest request,
            StreamObserver<ObjectType> responseObserver) {
        try {
            var objectType = schemaService.getObjectType(
                request.getRid(),
                request.hasVersion() ? request.getVersion().getValue() : null
            );
            responseObserver.onNext(mapper.toProto(objectType));
            responseObserver.onCompleted();
        } catch (NotFoundException e) {
            responseObserver.onError(Status.NOT_FOUND
                .withDescription(e.getMessage())
                .augmentDescription("rid=" + request.getRid())
                .asRuntimeException());
        }
    }

    @Override
    public void listObjectTypes(
            ListObjectTypesRequest request,
            StreamObserver<ListObjectTypesResponse> responseObserver) {
        var page = schemaService.listObjectTypes(
            request.getNamespaceRid(),
            request.getStatusFilter(),
            request.getPageSize(),
            request.getPageToken()
        );
        var response = ListObjectTypesResponse.newBuilder()
            .addAllObjectTypes(page.items().stream()
                .map(mapper::toProto)
                .toList())
            .setNextPageToken(page.nextToken())
            .setTotalCount(page.totalCount())
            .build();
        responseObserver.onNext(response);
        responseObserver.onCompleted();
    }

    @Override
    public void watchSchemaChanges(
            WatchSchemaChangesRequest request,
            StreamObserver<SchemaChangeEvent> responseObserver) {
        var subscription = schemaService.subscribeChanges(
            request.getNamespaceRid(),
            event -> {
                responseObserver.onNext(mapper.toChangeEvent(event));
            }
        );
        Context.current().addListener(
            context -> subscription.cancel(),
            MoreExecutors.directExecutor()
        );
    }
}

#4.3 Configuration

YAML
grpc:
  server:
    port: 9090
    security:
      enabled: true
      certificate-chain: classpath:certs/server.crt
      private-key: classpath:certs/server.key
    max-inbound-message-size: 4MB
    max-inbound-metadata-size: 8KB
    keep-alive-time: 30s
    keep-alive-timeout: 5s
    permit-keep-alive-without-calls: true

  client:
    data-Layer:
      address: dns:///data-Layer.internal:9090
      negotiation-type: TLS
      enable-keep-alive: true
      keep-alive-time: 30s
    intelligence-Layer:
      address: dns:///intelligence-Layer.internal:9090
      negotiation-type: TLS

#5. Interceptor Chain Design

#5.1 Interceptor Architecture

gRPC's interceptor mechanism is similar to Servlet Filters, supporting injection of cross-cutting logic before and after request processing. We designed the following interceptor chain (in execution order):

  1. Request Tracing Interceptor — Inject Trace ID
  2. Authentication Interceptor — Validate JWT/API Key
  3. Tenant Context Interceptor — Set tenant context
  4. Rate Limiting Interceptor — Check rate limits
  5. Logging Interceptor — Log request/response
  6. Metrics Interceptor — Collect Prometheus metrics
  7. Exception Translation Interceptor — Unified exception handling

#5.2 Authentication Interceptor

Java
@Component
public class AuthInterceptor implements ServerInterceptor {

    private static final Metadata.Key<String> AUTH_HEADER =
        Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER);

    private static final Context.Key<UserContext> USER_CONTEXT =
        Context.key("user-context");

    private final JwtValidator jwtValidator;

    @Override
    public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
            ServerCall<ReqT, RespT> call,
            Metadata headers,
            ServerCallHandler<ReqT, RespT> next) {

        String authHeader = headers.get(AUTH_HEADER);
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            call.close(Status.UNAUTHENTICATED
                .withDescription("Missing or invalid authorization header"),
                new Metadata());
            return new ServerCall.Listener<>() {};
        }

        try {
            String token = authHeader.substring(7);
            UserContext userContext = jwtValidator.validate(token);
            Context context = Context.current()
                .withValue(USER_CONTEXT, userContext);
            return Contexts.interceptCall(context, call, headers, next);
        } catch (InvalidTokenException e) {
            call.close(Status.UNAUTHENTICATED
                .withDescription("Invalid token: " + e.getMessage()),
                new Metadata());
            return new ServerCall.Listener<>() {};
        }
    }
}

#5.3 Metrics Interceptor

Java
@Component
public class MetricsInterceptor implements ServerInterceptor {

    private final MeterRegistry meterRegistry;

    @Override
    public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
            ServerCall<ReqT, RespT> call,
            Metadata headers,
            ServerCallHandler<ReqT, RespT> next) {

        String methodName = call.getMethodDescriptor().getFullMethodName();
        Timer.Sample sample = Timer.start(meterRegistry);

        return next.startCall(new ForwardingServerCall.SimpleForwardingServerCall<>(call) {
            @Override
            public void close(Status status, Metadata trailers) {
                sample.stop(Timer.builder("grpc.server.calls")
                    .tag("method", methodName)
                    .tag("status", status.getCode().name())
                    .register(meterRegistry));

                meterRegistry.counter("grpc.server.calls.total",
                    "method", methodName,
                    "status", status.getCode().name()
                ).increment();

                super.close(status, trailers);
            }
        }, headers);
    }
}

#5.4 Rate Limiting Interceptor

Java
@Component
public class RateLimitInterceptor implements ServerInterceptor {

    private final RedisTemplate<String, String> redisTemplate;
    private final RedisScript<Long> rateLimitScript;

    @Override
    public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
            ServerCall<ReqT, RespT> call,
            Metadata headers,
            ServerCallHandler<ReqT, RespT> next) {

        UserContext user = AuthInterceptor.USER_CONTEXT.get();
        if (user == null) {
            return next.startCall(call, headers);
        }

        String key = "ratelimit:" + user.getTenantId();
        Long result = redisTemplate.execute(rateLimitScript,
            List.of(key), "1000", "1");

        if (result != null && result == 0) {
            call.close(Status.RESOURCE_EXHAUSTED
                .withDescription("Rate limit exceeded"),
                new Metadata());
            return new ServerCall.Listener<>() {};
        }

        return next.startCall(call, headers);
    }
}

#6. Error Handling and Status Code Mapping

#6.1 Status Code Mapping Table

Business ExceptiongRPC Status CodeHTTP Equivalent
Resource not foundNOT_FOUND404
Invalid parameterINVALID_ARGUMENT400
Insufficient permissionsPERMISSION_DENIED403
Resource conflictALREADY_EXISTS409
Precondition not metFAILED_PRECONDITION412
Internal errorINTERNAL500
Service unavailableUNAVAILABLE503
TimeoutDEADLINE_EXCEEDED504

#6.2 Global Exception Handling

Java
@Component
public class GlobalExceptionInterceptor implements ServerInterceptor {

    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionInterceptor.class);

    @Override
    public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
            ServerCall<ReqT, RespT> call,
            Metadata headers,
            ServerCallHandler<ReqT, RespT> next) {

        return new ExceptionHandlingListener<>(
            next.startCall(call, headers), call);
    }

    private class ExceptionHandlingListener<ReqT, RespT>
            extends ForwardingServerCallListener.SimpleForwardingServerCallListener<ReqT> {

        private final ServerCall<ReqT, RespT> call;

        @Override
        public void onHalfClose() {
            try {
                super.onHalfClose();
            } catch (BusinessException e) {
                Status status = mapBusinessException(e);
                call.close(status, buildErrorMetadata(e));
            } catch (Exception e) {
                log.error("Unexpected error in gRPC call: {}",
                    call.getMethodDescriptor().getFullMethodName(), e);
                call.close(Status.INTERNAL
                    .withDescription("Internal server error"),
                    new Metadata());
            }
        }
    }

    private Status mapBusinessException(BusinessException e) {
        return switch (e) {
            case NotFoundException nfe -> Status.NOT_FOUND
                .withDescription(nfe.getMessage());
            case ConflictException ce -> Status.ALREADY_EXISTS
                .withDescription(ce.getMessage());
            case ValidationException ve -> Status.INVALID_ARGUMENT
                .withDescription(ve.getMessage());
            default -> Status.INTERNAL
                .withDescription(e.getMessage());
        };
    }
}

#6.3 Rich Error Model

gRPC supports structured error details in error responses:

Java
private StatusRuntimeException buildRichError(
        ValidationException e) {
    var violations = e.getViolations().stream()
        .map(v -> BadRequest.FieldViolation.newBuilder()
            .setField(v.getField())
            .setDescription(v.getMessage())
            .build())
        .toList();

    var badRequest = BadRequest.newBuilder()
        .addAllFieldViolations(violations)
        .build();

    return StatusProto.toStatusRuntimeException(
        com.google.rpc.Status.newBuilder()
            .setCode(Code.INVALID_ARGUMENT_VALUE)
            .setMessage(e.getMessage())
            .addDetails(Any.pack(badRequest))
            .build()
    );
}

#7. Client-Side Load Balancing

#7.1 DNS Service Discovery

In Kubernetes environments, we use DNS for service discovery. gRPC's dns:/// address scheme supports automatic resolution of multiple endpoints from Headless Services:

Java
ManagedChannel channel = ManagedChannelBuilder
    .forTarget("dns:///data-Layer-headless.onto.svc.cluster.local:9090")
    .defaultLoadBalancingPolicy("round_robin")
    .usePlaintext()
    .build();

#7.2 Client-Side Load Balancing

gRPC natively supports client-side load balancing. Combined with DNS service discovery, clients can distribute requests directly to multiple server instances:

Java
@Bean
@GrpcClient("data-Layer")
ManagedChannel dataPlaneChannel() {
    return ManagedChannelBuilder
        .forTarget("dns:///data-Layer.internal:9090")
        .defaultLoadBalancingPolicy("round_robin")
        .enableRetry()
        .maxRetryAttempts(3)
        .keepAliveTime(30, TimeUnit.SECONDS)
        .keepAliveTimeout(5, TimeUnit.SECONDS)
        .build();
}

#7.3 Retry Policy

gRPC retry policies are defined through service configuration:

JSON
{
  "methodConfig": [{
    "name": [{"service": "com.onto.control.v1.SchemaRegistryService"}],
    "retryPolicy": {
      "maxAttempts": 3,
      "initialBackoff": "0.1s",
      "maxBackoff": "1s",
      "backoffMultiplier": 2,
      "retryableStatusCodes": ["UNAVAILABLE", "DEADLINE_EXCEEDED"]
    }
  }]
}

Only idempotent operations should be configured for automatic retry. Non-idempotent operations (like CreateObjectType) should use hedging strategies or explicit application-layer retries.

#8. Health Checking

#8.1 gRPC Health Checking Protocol

gRPC defines a standard health checking protocol (grpc.health.v1.Health). Spring Boot integration:

Java
@Component
public class HealthService extends HealthGrpc.HealthImplBase {

    private final DataSource dataSource;
    private final RedisTemplate<String, String> redisTemplate;

    @Override
    public void check(HealthCheckRequest request,
                      StreamObserver<HealthCheckResponse> responseObserver) {
        var status = checkDependencies()
            ? ServingStatus.SERVING
            : ServingStatus.NOT_SERVING;

        responseObserver.onNext(HealthCheckResponse.newBuilder()
            .setStatus(status)
            .build());
        responseObserver.onCompleted();
    }

    private boolean checkDependencies() {
        try {
            dataSource.getConnection().isValid(1);
            redisTemplate.getConnectionFactory().getConnection().ping();
            return true;
        } catch (Exception e) {
            return false;
        }
    }
}

#8.2 Kubernetes Integration

YAML
livenessProbe:
  grpc:
    port: 9090
  initialDelaySeconds: 10
  periodSeconds: 10
readinessProbe:
  grpc:
    port: 9090
  initialDelaySeconds: 5
  periodSeconds: 5

Kubernetes 1.24+ natively supports gRPC health check probes without additional sidecars or tools.

#9. Testing Strategy

#9.1 Unit Testing

Using the grpc-testing library's InProcessServer for unit testing, avoiding real network communication:

Java
@ExtendWith(SpringExtension.class)
class SchemaRegistryServiceTest {

    @RegisterExtension
    static GrpcCleanupRule grpcCleanup = new GrpcCleanupRule();

    private SchemaRegistryServiceGrpc.SchemaRegistryServiceBlockingStub stub;

    @BeforeEach
    void setup() {
        String serverName = InProcessServerBuilder.generateName();
        grpcCleanup.register(InProcessServerBuilder
            .forName(serverName)
            .directExecutor()
            .addService(new SchemaRegistryServiceImpl(mockSchemaService, mapper))
            .build()
            .start());

        stub = SchemaRegistryServiceGrpc.newBlockingStub(
            grpcCleanup.register(InProcessChannelBuilder
                .forName(serverName)
                .directExecutor()
                .build()));
    }

    @Test
    void getObjectType_existingType_returnsType() {
        when(mockSchemaService.getObjectType("ri.onto.main.object-type.Employee", null))
            .thenReturn(testObjectType);

        ObjectType result = stub.getObjectType(
            GetObjectTypeRequest.newBuilder()
                .setRid("ri.onto.main.object-type.Employee")
                .build());

        assertThat(result.getRid()).isEqualTo("ri.onto.main.object-type.Employee");
        assertThat(result.getApiName()).isEqualTo("Employee");
    }

    @Test
    void getObjectType_nonExistent_throwsNotFound() {
        when(mockSchemaService.getObjectType(anyString(), any()))
            .thenThrow(new NotFoundException("Object type not found"));

        StatusRuntimeException exception = assertThrows(
            StatusRuntimeException.class,
            () -> stub.getObjectType(
                GetObjectTypeRequest.newBuilder()
                    .setRid("ri.onto.main.object-type.NonExistent")
                    .build()));

        assertThat(exception.getStatus().getCode()).isEqualTo(Status.Code.NOT_FOUND);
    }
}

#9.2 Integration Testing

Using Testcontainers for full integration testing:

Java
@SpringBootTest
@Testcontainers
class SchemaRegistryIntegrationTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");

    @Container
    static GenericContainer<?> redis = new GenericContainer<>("redis:7")
        .withExposedPorts(6379);

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.redis.host", redis::getHost);
        registry.add("spring.redis.port", () -> redis.getMappedPort(6379));
    }

    @Test
    void fullLifecycle_createUpdateDelete() {
        // Full lifecycle test: create, query, update, delete
    }
}

#10. Progressive Migration from REST to gRPC

#10.1 gRPC-Gateway

For transition periods requiring simultaneous REST and gRPC support, we use gRPC-Gateway or grpc-spring-boot-starter's REST mapping capabilities:

PROTOBUF
import "google/api/annotations.proto";

service SchemaRegistryService {
    rpc GetObjectType(GetObjectTypeRequest) returns (ObjectType) {
        option (google.api.http) = {
            get: "/api/v1/object-types/{rid}"
        };
    }
}

#10.2 Migration Roadmap

  1. Phase 1: New services use gRPC directly; legacy services retain REST
  2. Phase 2: Add gRPC interfaces to legacy services; run both protocols in parallel
  3. Phase 3: All internal calls switch to gRPC; REST serves only as external API gateway
  4. Phase 4: Use Envoy or grpc-web to provide gRPC-Web support for web frontends

In the Ontology platform, we are currently in Phase 3. Internal inter-Layer communication fully uses gRPC, while external APIs are served through a Kong gateway providing REST interfaces.

#Key Takeaways

  1. gRPC is the optimal choice for internal communication — binary serialization, strong-typed contracts, and streaming support make it superior to REST in microservice scenarios.
  2. Protobuf design is a long-term investment — field number allocation, version management, and compatibility strategies must be established early in the project.
  3. Interceptor chains elegantly implement cross-cutting concerns — authentication, rate limiting, logging, and metrics achieve loose coupling through interceptor chains.
  4. Error handling needs standardization — unified status code mapping and the Rich Error Model improve cross-team collaboration efficiency.
  5. Progressive migration reduces risk — through gRPC-Gateway and parallel protocol support, migration can be completed without service disruption.

#Next Article

The next article, S8-14: Quarkus Reactive Programming, will deep dive into the reactive programming model in the Data Layer using Quarkus, including Mutiny reactive streams, Vert.x event loops, non-blocking I/O optimization, and integration practices with Iceberg + Nessie.

tags: [spring-boot, grpc, protobuf, interceptor, error-handling, load-balancing, health-check, control-Layer, ontology-paas, S8]