Creating a Delete Function in Spring Microservices — Real-World Step-by-Step

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 coreDataID string (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 GeoSseEvent model 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

  1. Send DELETE /auth/adventuretubedata/youtube/{youtubeContentID} with JWT
  2. Listen for SSE events
  3. On terminal success event containing coreDataID → delete from local Core Data
  4. On error event → show message to user

Leave a Comment

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

Scroll to Top