Goal: Create a delete function across microservices with ownership verification. Phase 1 builds the basic delete flow. Phase 2 adds SSE for real-time feedback to iOS.
Overview
This guide walks through creating a delete AdventureTubeData feature across a microservice architecture: from exception handling, through service/controller layers in two services, to testing with Postman before building the iOS client.
Architecture flow:
iOS → Gateway → Auth-Service (extract JWT email) → Geospatial-Service (ownership check + delete) → MongoDB
Phase 1: Basic Delete (HTTP Request/Response)
Step 1: Define Error Code (Geospatial-Service)
Add the new error to the enum that defines all possible errors with their HTTP status.
File: GeoErrorCode.java
OWNERSHIP_MISMATCH("AdventureTubeData ownership email does not match", HttpStatus.FORBIDDEN),
Use 403 FORBIDDEN (not 401). 401 = “who are you?” (bad JWT). 403 = “I know who you are, but you can’t do this” (ownership mismatch).
Step 2: Create Custom Exception (Geospatial-Service)
Create a simple exception class that carries the error code. Thrown from business logic.
File: OwnershipMismatchException.java
package com.adventuretube.geospatial.exceptions;
import com.adventuretube.geospatial.exceptions.code.GeoErrorCode;
import lombok.Getter;
@Getter
public class OwnershipMismatchException extends RuntimeException {
private final GeoErrorCode errorCode;
public OwnershipMismatchException(GeoErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
}
}
Step 3: Add Exception Handler (Geospatial-Service)
Register the handler in GlobalExceptionHandler so Spring converts the exception to an HTTP response automatically.
File: GlobalExceptionHandler.java
@ExceptionHandler(OwnershipMismatchException.class)
public ResponseEntity<ServiceResponse<?>> handleOwnershipMismatchException(
OwnershipMismatchException ex) {
ServiceResponse<?> response = ServiceResponse.builder()
.success(false)
.message(ex.getErrorCode().getMessage())
.errorCode(ex.getErrorCode().name())
.data(null)
.timestamp(java.time.LocalDateTime.now())
.build();
return ResponseEntity.status(ex.getErrorCode().getHttpStatus()).body(response);
}
Exception Architecture: Enum defines the error → Exception class carries it → GlobalExceptionHandler catches it and converts to HTTP response. This 3-layer pattern is used for every error type.
Step 4: Service Layer — Business Logic (Geospatial-Service)
Implement the core logic: find document, verify ownership, delete, return coreDataID.
File: AdventureTubeDataService.java
public String deleteByYoutubeContentIdAndOwnerEmail(String youtubeContentId, String ownerEmail) {
AdventureTubeData data = repository.findByYoutubeContentID(youtubeContentId)
.orElseThrow(() -> new IllegalArgumentException(
"AdventureTubeData not found with youtubeContentID: " + youtubeContentId));
if (!data.getOwnerEmail().equals(ownerEmail)) {
throw new OwnershipMismatchException(GeoErrorCode.OWNERSHIP_MISMATCH);
}
repository.deleteById(data.getId());
return data.getCoreDataID();
}
Three possible outcomes:
| Outcome | Exception | HTTP Status |
|---|---|---|
| Document not found | IllegalArgumentException |
404 Not Found |
| Ownership mismatch | OwnershipMismatchException |
403 Forbidden |
| Success | — | 200 OK (returns coreDataID) |
Step 5: Controller Endpoint (Geospatial-Service)
Expose the delete as a REST endpoint. Receives youtubeContentId and ownerEmail as query parameters.
File: AdventureTubeDataController.java
@DeleteMapping("/data/delete/adventuretubedata")
public ResponseEntity<String> deleteByYoutubeContentIdAndOwnerEmail(
@RequestParam String youtubeContentId,
@RequestParam String ownerEmail) {
log.info("DELETE /geo/data/delete/adventuretubedata youtubeContentId={}, ownerEmail={}",
youtubeContentId, ownerEmail);
String coreDataId = adventureTubeDataService
.deleteByYoutubeContentIdAndOwnerEmail(youtubeContentId, ownerEmail);
return ResponseEntity.ok(coreDataId);
}
Step 6: Service Layer — Auth Proxy (Auth-Service)
Extract email from JWT token and forward to geospatial-service. Auth-service doesn’t touch business logic — it only does auth.
File: AuthService.java
public Mono<String> deleteAdventuretubeData(String authorization, String youtubeContentId) {
return Mono.fromCallable(() -> {
String token = TokenSanitizer.sanitize(authorization);
return jwtUtil.extractUsername(token);
})
.flatMap(ownerEmail -> serviceClient.deleteRawReactive(
geoServiceUrl,
"/geo/data/delete/adventuretubedata?youtubeContentId="
+ youtubeContentId + "&ownerEmail=" + ownerEmail,
new ParameterizedTypeReference<String>() {}))
.onErrorMap(ServiceClientException.class, this::mapServiceClientException);
}
Step 7: Controller Endpoint (Auth-Service)
Create the external-facing endpoint that iOS calls. Requires JWT in Authorization header.
File: AuthController.java
@DeleteMapping("/adventuretubedata/youtube/{youtubeContentId}")
public Mono<ResponseEntity<?>> deleteAdventuretubeData(
@RequestHeader("Authorization") String authorization,
@PathVariable String youtubeContentId) {
return authService.deleteAdventuretubeData(authorization, youtubeContentId)
.map(coreDataId -> ResponseEntity.ok().body(coreDataId));
}
Step 8: ServiceClient — Inter-Service Communication (Common-API)
Add the shared HTTP client method for DELETE calls between services. Follows the same pattern as postRawReactive and getRawReactive.
File: ServiceClient.java
public <T> Mono<T> deleteRawReactive(String baseUrl, String path,
ParameterizedTypeReference<T> responseType) {
WebClient webClient = webClientBuilder.baseUrl(baseUrl).build();
String serviceName = extractServiceName(baseUrl);
ReactiveCircuitBreaker circuitBreaker = circuitBreakerFactory.create(serviceName);
Mono<T> call = webClient.delete()
.uri(path)
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError, response -> {
log.error("{} 4xx error on DELETE {}", serviceName, path);
return Mono.error(new ServiceClient4xxException(
serviceName, "CLIENT_ERROR",
serviceName + " returned " + response.statusCode().value(),
response.statusCode().value()));
})
.onStatus(HttpStatusCode::is5xxServerError, response -> {
log.error("{} 5xx error on DELETE {}", serviceName, path);
return Mono.error(new ServiceClient5xxException(
serviceName, "SERVER_ERROR",
serviceName + " returned " + response.statusCode().value(),
response.statusCode().value()));
})
.bodyToMono(responseType)
.timeout(DEFAULT_TIMEOUT)
.onErrorMap(WebClientRequestException.class, ex ->
new ServiceClient5xxException(serviceName, "SERVER_NOT_AVAILABLE",
serviceName + " is not available", 503))
.onErrorMap(java.util.concurrent.TimeoutException.class, ex ->
new ServiceClient5xxException(serviceName, "SERVER_NOT_AVAILABLE",
serviceName + " timed out", 503));
return circuitBreaker.run(call, throwable -> {
if (throwable instanceof ServiceClient4xxException) {
return Mono.error(throwable);
}
return Mono.error(new ServiceClient5xxException(
serviceName, "CIRCUIT_OPEN",
serviceName + " circuit breaker is open", 503));
});
}
Step 9: Unit Tests (Geospatial-Service)
Test the three outcomes: success, not found, ownership mismatch.
@Test
void deleteByYoutubeContentId_success() {
// Given: document exists with matching ownerEmail
// When: deleteByYoutubeContentIdAndOwnerEmail("V76WfWIWYOs", "user@gmail.com")
// Then: document deleted, coreDataID returned
}
@Test
void deleteByYoutubeContentId_notFound() {
// Given: no document with this youtubeContentId
// When: deleteByYoutubeContentIdAndOwnerEmail("NONEXISTENT", "user@gmail.com")
// Then: throws IllegalArgumentException
}
@Test
void deleteByYoutubeContentId_ownershipMismatch() {
// Given: document exists but ownerEmail is different
// When: deleteByYoutubeContentIdAndOwnerEmail("V76WfWIWYOs", "wrong@gmail.com")
// Then: throws OwnershipMismatchException
}
Step 10: Postman Testing
Test via gateway before building iOS:
DELETE {{baseUrl}}/auth/adventuretubedata/youtube/V76WfWIWYOs
Authorization: Bearer {{access_token}}
Expected responses:
- 200 — returns
coreDataIDstring (success) - 403 — ownership mismatch
- 404 — youtubeContentID not found
Phase 2: Add SSE for iOS Data Sync
After Phase 1 is verified with Postman, refactor the auth-service delete endpoint to return Flux<ServerSentEvent<GeoSseEvent>> instead of Mono<ResponseEntity>. SSE gives iOS real-time step-by-step feedback and the coreDataID in the final event triggers local Core Data deletion.
- Refactor auth-service delete to return SSE stream
- Create
GeoSseEventmodel class - Implement SSE streaming in
GeoWriteService
SSE delete flow:
iOS opens SSE connection → DELETE /auth/adventuretubedata/youtube/{youtubeContentID}
Event 1: {"step": "auth", "message": "Token validated", "terminal": false}
Event 2: {"step": "ownership", "message": "Ownership verified", "terminal": false}
Event 3: {"step": "deleted", "youtubeContentID": "V76WfWIWYOs",
"coreDataID": "70618FBE-...", "terminal": true}
iOS receives terminal event → deletes from local Core Data → closes connection
Phase 3: iOS Implementation
- Send
DELETE /auth/adventuretubedata/youtube/{youtubeContentID}with JWT - Listen for SSE events
- On terminal success event containing
coreDataID→ delete from local Core Data - On error event → show message to user
