AdventureTube Microservice Request/Response Architecture

1. Service Connection Pattern

The first architecture decision for a microservice platform: how do services connect to each other? Everything else — response format, DTOs, error handling — flows from this.

Service Routing

The Gateway routes requests based on whether the operation requires authentication:

Authenticated (writes/user ops):  iOS → Gateway → Auth Service → Geospatial / Member Service
Public (reads, no auth):          iOS → Gateway → Web Service  → Geospatial Service
Internal only:                    /geo/** and /member/** not exposed through Gateway
  • Auth-service handles all operations that require authentication — user registration, login, token management, and all write operations (create/update/delete content). It validates the JWT, extracts the user identity, and forwards to the appropriate leaf service.
  • Web-service handles all public read operations that don’t require authentication — browsing content, searching by category, etc. No JWT needed.
  • Member-service and Geospatial-service are internal only — they are never exposed through the Gateway. No client can reach them directly. They only accept calls from auth-service and web-service within the internal network. This is by design: leaf services have no authentication logic and trust that the caller has already validated the request.

Who Calls Whom

iOS Client → Gateway :8030
  ├── Authenticated (writes, user ops) → Auth Service :8010 (Netty/Reactive)
  │     ├── → Member Service :8070 (Tomcat/Blocking)
  │     └── → Geospatial Service :8060 (Tomcat/Blocking)
  └── Public (reads, no auth) → Web Service :8040 (Tomcat/Blocking)
        └── → Geospatial Service :8060 (Tomcat/Blocking)

Geospatial Service → Kafka (async data ingestion)
Geospatial Service → MongoDB
Member Service → PostgreSQL

All inter-service communication is HTTP REST. Kafka is used for async data ingestion (write path), not for request-response.


2. Caller vs Leaf Services

Callers make outbound HTTP calls to other services → need an HTTP client.
Leaf services only receive calls and write to their database → no HTTP client needed.

Service Role Server Makes outbound HTTP? HTTP Client Why
Auth-service Caller Netty Yes → member, geospatial WebClient (reactive) Netty event-loop — .block() would freeze all requests
Web-service Caller Tomcat → Netty (soon) Yes → geospatial WebClient (reactive) Migrating to WebFlux for SSE/streaming — needs full reactive stack to proxy Flux SSE streams
Member-service Leaf Tomcat No None (use RestClient if ever needed) Receives calls, writes to PostgreSQL via JPA/JDBC
Geospatial-service Leaf Tomcat No None (use RestClient if ever needed) Receives calls, publishes to Kafka, writes to MongoDB

Why Callers Use WebClient

  • Auth-service — runs on Netty. Must use reactive chains (Mono/Flux). No choice.
  • Web-service — currently runs on Tomcat, but will migrate to WebFlux (Netty) soon for SSE/streaming support. Web-service needs to proxy reactive Flux SSE streams from geospatial-service to the iOS client — this requires a full reactive stack, not Tomcat + .block().

Why Leaf Services Use RestClient (Not WebClient)

Leaf services (member, geospatial) don’t currently make outbound HTTP calls. If they ever do, they should use RestClient — the modern blocking HTTP client for Tomcat services. Not WebClient + .block():

  • RestClient is the natural fit for blocking Tomcat services
  • No .block() anti-pattern, no reactive complexity
  • Leaf services are not part of the reactive pipeline — using WebClient adds complexity for zero benefit

3. ServiceClient (common-api)

ServiceClient is the shared HTTP client in common-api. It is built entirely on WebClient and provides reactive methods only. Only caller services (auth, web) use it. Leaf services (member, geo) never touch it.

Design

ServiceClient provides two method families based on what the target service returns:

Method Family Target Returns Used When
*ServiceResponse*() ServiceResponse<T> Calling member-service (all endpoints return ServiceResponse)
*Raw*() Plain entity (String, JsonNode, etc.) Calling geospatial-service (success returns raw, errors return ServiceResponse)

TODO (Phase 2): Standardize geospatial-service to return ServiceResponse<T> on success — same as member-service. This will eliminate all *Raw*() methods from ServiceClient.

Why Raw Returns Must Be Eliminated

Geospatial-service currently returns raw types on success (ResponseEntity<AdventureTubeData>, ResponseEntity<Void>) while its GlobalExceptionHandler returns ServiceResponse on errors. This creates problems:

1. Inconsistent response shape — The caller must handle two different JSON structures from the same endpoint: a raw entity on success, and a ServiceResponse envelope on error. This is the root cause of the *Raw*() / *ServiceResponse*() method split in ServiceClient.

2. Swagger/OpenAPI ambiguity — Swagger auto-generates the success schema from the return type, but the error schema (from GlobalExceptionHandler) is a completely different structure. API consumers and iOS client codegen see inconsistent contracts.

3. Lost context on empty bodiesResponseEntity<Void> on delete and ResponseEntity.notFound().build() return empty bodies. ServiceClient tries to parse the error body as ServiceResponse, gets nothing, and falls back to a generic error message. The original reason is lost.

4. ServiceClient duplication — The split forces duplicate public methods (postRawReactive + postServiceResponseReactive, etc.) and duplicate error handlers. Standardizing eliminates the Raw variants entirely — roughly half the methods disappear.

Error Handling in ServiceClient

Both method families handle errors the same way — because GlobalExceptionHandler on every service always returns ServiceResponse on errors:

Error Type Exception Thrown Contains
4xx from target ServiceClientException (isClientError()=true) errorCode, message, HTTP status from ServiceResponse body
5xx from target ServiceClientException (isServerError()=true) errorCode, message, HTTP status from ServiceResponse body
Network failure ServiceClientException (isServerError()=true) SERVER_NOT_AVAILABLE
Circuit breaker open ServiceClientException (isServerError()=true) CIRCUIT_OPEN

Completed (Phase 1): ServiceClient4xxException and ServiceClient5xxException have been merged into a single ServiceClientException with isClientError() / isServerError() methods. The subclasses added no fields or behavior — they were redundant type markers. The circuit breaker uses sce.getHttpStatus() >= 500 on the base class.

4xx errors pass through the circuit breaker (they are client errors, not server failures). 5xx and network errors trigger the circuit breaker.

Who Consumes How

  • Auth-service (Netty) — calls *Reactive() methods, chains the Mono into its reactive pipeline
  • Web-service (Tomcat) — calls *Reactive() methods, then .block() at the call site for synchronous results. Will use Mono/Flux directly after migration to WebFlux (Netty) for SSE/streaming

4. Response Contract: ServiceResponse<T>

Every HTTP response body from every service must be ServiceResponse<T> — both success and error. This is enforced by GlobalExceptionHandler on the error path, and must be adopted on the success path for consistency.

Why All Endpoints Must Use ServiceResponse

GlobalExceptionHandler with @ControllerAdvice already wraps every error in ServiceResponse. This means endpoints that return raw types on success create an inconsistent contract:

Success Response Error Response Consistent?
Member-service ServiceResponse<T> ServiceResponse<T> Yes
Geospatial-service Raw (AdventureTubeData, List<>, Void) ServiceResponse<T> No — two shapes from same endpoint

The caller (ServiceClient) has to handle two different response shapes from the same endpoint depending on success vs error. This is the root cause of the *Raw* vs *ServiceResponse* method split in ServiceClient.

Structure

HTTP Response
├── Status: 200 / 403 / 404 / 500           ← ResponseEntity controls this
├── Headers: Content-Type, etc.
└── Body:
    └── ServiceResponse<T>                   ← standardized envelope
        ├── success: true/false
        ├── errorCode: "OWNERSHIP_MISMATCH"  ← null on success
        ├── message: "..."
        ├── data: <T>                        ← the actual DTO or entity
        └── timestamp: "2026-03-07T10:30:00"

ResponseEntity — The HTTP Wrapper

ResponseEntity is Spring’s class that controls HTTP status code and headers. It is not serialized to JSON — only the .body() is.

return ResponseEntity.status(HttpStatus.FORBIDDEN).body(serviceResponse);
//     └── Status: 403    └── Headers: auto    └── Body: serialized to JSON
Method Status Use Case
ResponseEntity.ok(body) 200 Successful response
ResponseEntity.created(uri).body(body) 201 Resource created
ResponseEntity.accepted().body(body) 202 Async task accepted (Kafka publish)
ResponseEntity.noContent().build() 204 Delete success, no body
ResponseEntity.status(HttpStatus.FORBIDDEN).body(body) 403 Custom error status

Success vs Error Examples

Success:

{
  "success": true,
  "message": "User found",
  "errorCode": null,
  "data": {
    "email": "strider.lee@gmail.com",
    "username": "strider"
  },
  "timestamp": "2026-03-07T10:30:00"
}

Error:

{
  "success": false,
  "message": "AdventuretubeData ownership email is not matched",
  "errorCode": "OWNERSHIP_MISMATCH",
  "data": null,
  "timestamp": "2026-03-07T10:30:00"
}

5. Error Propagation Across Services

When an exception occurs in a leaf service, it must travel back through the caller service to the iOS client without losing information.

The Flow

Geo-service: OwnershipMismatchException thrown
    ↓
GlobalExceptionHandler catches it
    ↓
Returns ResponseEntity(403) + ServiceResponse { errorCode: "OWNERSHIP_MISMATCH" }
    ↓
ServiceClient receives 403 + ServiceResponse body
    ↓
Creates ServiceClientException { errorCode: "OWNERSHIP_MISMATCH", status: 403 }
    ↓
Auth-service GlobalExceptionHandler builds ServiceResponse from ServiceClientException fields
    ↓
Returns 403 to iOS with original errorCode preserved

Error Code Enums Per Service

Each service defines its own error vocabulary:

Service Enum Example Codes
Geospatial GeoErrorCode DATA_NOT_FOUND, OWNERSHIP_MISMATCH, DUPLICATE_KEY
Auth AuthErrorCode GOOGLE_TOKEN_INVALID, USER_NOT_FOUND, TOKEN_SAVE_FAILED
Member MemberErrorCode USER_EMAIL_DUPLICATE, USER_NOT_FOUND

The Mapping Problem — Solved

Fixed (Phase 1): mapServiceClientException() has been removed from both auth-service and web-service. ServiceClientException now propagates directly to GlobalExceptionHandler, which builds ServiceResponse from its fields (errorCode, message, httpStatus). Downstream error codes like OWNERSHIP_MISMATCH pass through faithfully — no more silent remapping to INTERNAL_ERROR.

The previous approach had a fragile switch statement that silently dropped unknown error codes:

// DELETED — auth-service mapServiceClientException()
return switch (errorCode) {
    case "USER_EMAIL_DUPLICATE" -> ...   // member-service codes
    case "USER_NOT_FOUND" -> ...
    case "TOKEN_NOT_FOUND" -> ...
    // No OWNERSHIP_MISMATCH!
    // No DATA_NOT_FOUND (from geo)!
    default -> new InternalServerException(AuthErrorCode.INTERNAL_ERROR);  // ← was a bug
};

Now GlobalExceptionHandler in each caller service has a single handler:

@ExceptionHandler(ServiceClientException.class)
public ResponseEntity<ServiceResponse<?>> handleServiceClientException(ServiceClientException ex) {
    ServiceResponse<?> response = ServiceResponse.builder()
            .success(false)
            .message(ex.getMessage())
            .errorCode(ex.getErrorCode())    // original downstream code preserved
            .data(ex.getServiceName() + " : auth-service")
            .timestamp(LocalDateTime.now())
            .build();
    return ResponseEntity.status(ex.getHttpStatus()).body(response);
}

6. DTOs and Entities

Why DTOs?

Both request and response use typed DTOs (Data Transfer Objects) — but for different reasons.

Request DTOs — Validation:

@PostMapping("/users")
public Mono<ResponseEntity<MemberRegisterResponse>> registerUser(
    @Valid @RequestBody Mono<MemberRegisterRequest> request) { ... }

@NotBlank, @Email, @Size annotations enforce rules before your code runs.

Response DTOs — API Contract:

// With DTO — compiler catches typos
res.getAccesToken();  // compile error — typo caught instantly

// With Map — compiles fine, breaks at runtime
res.put("accesToken", token);  // compiles — typo ships to production
Concern Typed DTO Map<String, Object>
Typo detection Compile time Runtime (or never)
Type safety String getAccessToken() (String) map.get("accessToken")
Swagger/OpenAPI Auto-generated from class Shows “any object”
iOS/Web client Mirrors DTO as Swift Codable struct “What keys are in this map?”
Refactoring Rename → compiler shows every usage Rename → nothing breaks until production

When to Skip DTOs — Pass-Through Proxy

Rule of thumb: If the service owns or validates the data → use a typed DTO. If the service just passes it through → use JsonNode.

Auth-service uses JsonNode for geo data because creating a DTO would couple it to geo-service’s domain:

// Auth-service — just stamps ownerEmail and forwards
@PostMapping("/adventuretubedata")
public Mono<ResponseEntity<?>> createAdventuretubeData(
    @RequestBody JsonNode data) { ... }  // JsonNode = untyped, no coupling

ServiceResponse<T> Needs a DTO Inside

ServiceResponse<T> is the envelope — but T is the response DTO:

ServiceResponse<MemberRegisterResponse>
    ├── success: true
    ├── message: "Registration successful"
    └── data:
          └── MemberRegisterResponse    ← T
                ├── userId
                ├── accessToken
                └── refreshToken

Request DTOs

DTO Service Fields Purpose
MemberRegisterRequest Auth email, password, username, role, googleIdToken, refreshToken, channelId User registration input
MemberLoginRequest Auth email, password, googleIdToken User login input

Response DTOs

DTO Service Fields Purpose
MemberRegisterResponse Auth userId, accessToken, refreshToken Registration result
MemberLoginResponse Auth accessToken, refreshToken, tokenType, expiresIn Login result
MemberDTO Common Member fields for transfer Member data between services
TokenDTO Common Token fields for transfer Token data between services

Entities — Database Models

Entity Service Database Annotation
Member Member PostgreSQL @Entity / @Table (JPA)
Token Member PostgreSQL @Entity / @Table (JPA)
AdventureTubeData Geospatial MongoDB @Document (Spring Data MongoDB)

AdventureTubeData doubles as both entity and request body in geo-service — used directly in @RequestBody without a separate DTO. This is a common shortcut when the API shape matches the database shape exactly.


7. Full Flow Example: Delete Content

iOS App
  ↓ DELETE /auth/adventuretubedata/abc123
  ↓ Header: Authorization: Bearer <jwt>

Auth Service (Reactive/Netty)
  ↓ Extract email from JWT
  ↓ Call geo-service via ServiceClient.deleteRawReactive()

Geo Service (Blocking/Tomcat)
  ↓ Find content by youtubeContentID
  ↓ Check ownerEmail matches
  ↓ If mismatch → throw OwnershipMismatchException
  ↓ GlobalExceptionHandler catches it
  ↓ Returns:
      ResponseEntity (403)
        └── ServiceResponse
              ├── success: false
              ├── errorCode: "OWNERSHIP_MISMATCH"
              ├── message: "AdventuretubeData ownership email is not matched"
              └── data: null

Auth Service
  ↓ ServiceClient receives 403 + ServiceResponse body
  ↓ Creates ServiceClientException { errorCode: "OWNERSHIP_MISMATCH" }
  ↓ GlobalExceptionHandler returns 403 to client

iOS App
  ← 403 Forbidden
  ← { "success": false, "errorCode": "OWNERSHIP_MISMATCH", ... }

8. Summary

Layer What Role
Connection Pattern HTTP REST between services Foundation — who calls whom, reactive vs blocking
Caller vs Leaf Auth/Web = callers (WebClient), Member/Geo = leaf (RestClient if needed) Determines which services need ServiceClient
ServiceClient Reactive WebClient wrapper in common-api Transport layer — carries requests, propagates errors
ServiceResponse<T> Standard envelope for all responses Consistent shape for success and error — every service, every endpoint
Error Propagation Exception → GlobalExceptionHandler → ServiceResponse → ServiceClient → Caller Errors travel back without losing information
DTOs / Entities Typed payloads inside ServiceResponse API contract, validation, type safety

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top