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
FluxSSE 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():
RestClientis the natural fit for blocking Tomcat services- No
.block()anti-pattern, no reactive complexity - Leaf services are not part of the reactive pipeline — using
WebClientadds 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 bodies — ResponseEntity<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 theMonointo its reactive pipeline - Web-service (Tomcat) — calls
*Reactive()methods, then.block()at the call site for synchronous results. Will useMono/Fluxdirectly 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 |
