Response
Empty directory — no existing project to match against, so I'll design the full vertical slice (exceptions → advice → service) from the stated conventions and print the source directly, as requested, rather than writing files.
**Design:** three domain exceptions (not found / wrong owner / wrong state), a `@RestControllerAdvice` mapping them to HTTP status + `ApiResponse.error`, and the service doing both authorization checks itself ("two locks on the door") even though a controller/security layer would also gate the route. A thin controller is included to show the full path to the client.
```java
package com.acme.billing;
public enum InvoiceStatus {
UNPAID,
PAID,
CANCELLED
}
```
```java
package com.acme.billing;
import java.util.Optional;
public interface InvoiceRepository {
Optional<Invoice> findById(long id);
}
```
```java
package com.acme.billing;
public interface Invoice {
long customerId();
InvoiceStatus status();
void markCancelled();
}
```
```java
package com.acme.billing.common;
public record ApiResponse<T>(boolean success, T data, ApiError error) {
public static <T> ApiResponse<T> ok(T data) {
return new ApiResponse<>(true, data, null);
}
public static <T> ApiResponse<T> error(String code, String message) {
return new ApiResponse<>(false, null, new ApiError(code, message));
}
public record ApiError(String code, String message) {}
}
```
```java
package com.acme.billing.exception;
public class InvoiceNotFoundException extends RuntimeException {
public InvoiceNotFoundException(long invoiceId) {
super("Invoice " + invoiceId + " not found");
}
}
```
```java
package com.acme.billing.exception;
public class InvoiceAccessDeniedException extends RuntimeException {
public InvoiceAccessDeniedException(long invoiceId) {
super("Invoice " + invoiceId + " does not belong to the current customer");
}
}
```
```java
package com.acme.billing.exception;
import com.acme.billing.InvoiceStatus;
public class InvoiceNotCancellableException extends RuntimeException {
public InvoiceNotCancellableException(long invoiceId, InvoiceStatus status) {
super("Invoice " + invoiceId + " cannot be cancelled because its status is " + status);
}
}
```
```java
package com.acme.billing.exception;
import com.acme.billing.common.ApiResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(InvoiceNotFoundException.class)
public ResponseEntity<ApiResponse<Void>> handleNotFound(InvoiceNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.error("INVOICE_NOT_FOUND", ex.getMessage()));
}
@ExceptionHandler(InvoiceAccessDeniedException.class)
public ResponseEntity<ApiResponse<Void>> handleAccessDenied(InvoiceAccessDeniedException ex) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("INVOICE_ACCESS_DENIED", ex.getMessage()));
}
@ExceptionHandler(InvoiceNotCancellableException.class)
public ResponseEntity<ApiResponse<Void>> handleNotCancellable(InvoiceNotCancellableException ex) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(ApiResponse.error("INVOICE_NOT_CANCELLABLE", ex.getMessage()));
}
}
```
```java
package com.acme.billing;
import com.acme.billing.exception.InvoiceAccessDeniedException;
import com.acme.billing.exception.InvoiceNotCancellableException;
import com.acme.billing.exception.InvoiceNotFoundException;
import org.springframework.stereotype.Service;
@Service
public class InvoiceService {
private final InvoiceRepository invoiceRepository;
public InvoiceService(InvoiceRepository invoiceRepository) {
this.invoiceRepository = invoiceRepository;
}
public void cancelInvoice(long customerId, long invoiceId) {
Invoice invoice = invoiceRepository.findById(invoiceId)
.orElseThrow(() -> new InvoiceNotFoundException(invoiceId));
if (invoice.customerId() != customerId) {
throw new InvoiceAccessDeniedException(invoiceId);
}
if (invoice.status() != InvoiceStatus.UNPAID) {
throw new InvoiceNotCancellableException(invoiceId, invoice.status());
}
invoice.markCancelled();
}
}
```
```java
package com.acme.billing;
import com.acme.billing.common.ApiResponse;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/invoices")
public class InvoiceController {
private final InvoiceService invoiceService;
public InvoiceController(InvoiceService invoiceService) {
this.invoiceService = invoiceService;
}
@PostMapping("/{invoiceId}/cancel")
public ResponseEntity<ApiResponse<Void>> cancel(
@PathVariable long invoiceId,
@AuthenticationPrincipal CustomerPrincipal principal) {
invoiceService.cancelInvoice(principal.customerId(), invoiceId);
return ResponseEntity.ok(ApiResponse.ok(null));
}
}
```
```java
package com.acme.billing;
public record CustomerPrincipal(long customerId) {}
```
Notes on the two non-obvious calls: `InvoiceController` assumes your security layer resolves an authenticated `CustomerPrincipal` (swap for whatever your `UserDetails`/JWT principal actually is) — everything else follows the given method signatures exactly. The service checks ownership and status itself rather than trusting the controller/security layer, per the "two locks on the door" convention, so the rule holds even if called from elsewhere.