Response
Here's the invoice cancellation feature for `com.acme.billing`, following the house Spring Boot conventions (constructor injection, domain exceptions mapped by a `@RestControllerAdvice`, wrapped `ApiResponse`).
```java
package com.acme.billing;
public class InvoiceNotFoundException extends RuntimeException {
public InvoiceNotFoundException(long invoiceId) {
super("Invoice " + invoiceId + " not found");
}
}
```
```java
package com.acme.billing;
public class InvoiceAccessDeniedException extends RuntimeException {
public InvoiceAccessDeniedException(long invoiceId) {
super("Invoice " + invoiceId + " does not belong to the current customer");
}
}
```
```java
package com.acme.billing;
public class InvoiceNotCancellableException extends RuntimeException {
public InvoiceNotCancellableException(long invoiceId, InvoiceStatus status) {
super("Invoice " + invoiceId + " cannot be cancelled because it is " + status);
}
}
```
```java
package com.acme.billing;
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;
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 org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class InvoiceCancellationService {
private final InvoiceRepository invoiceRepository;
public InvoiceCancellationService(InvoiceRepository invoiceRepository) {
this.invoiceRepository = invoiceRepository;
}
@Transactional
public void cancelInvoice(long invoiceId, long customerId) {
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 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 InvoiceCancellationService invoiceCancellationService;
public InvoiceController(InvoiceCancellationService invoiceCancellationService) {
this.invoiceCancellationService = invoiceCancellationService;
}
@PostMapping("/{id}/cancel")
public ApiResponse<Void> cancel(@PathVariable("id") long id, long customerId) {
invoiceCancellationService.cancelInvoice(id, customerId);
return ApiResponse.ok(null);
}
}
```
Convention decisions applied: authorization ("own invoice") and the business rule ("still unpaid") are enforced in the service even though a route guard may also exist, per the "two locks on the door" rule; failures are thrown as three distinct domain exceptions (404/403/409) and translated by one `@RestControllerAdvice`, so `InvoiceController` stays a thin pass-through with no try/catch.
Two assumptions since they weren't given: `InvoiceStatus` is an existing enum with an `UNPAID` constant that `Invoice.status()` returns, and `customerId` extraction from the authenticated principal in `InvoiceController` is left as a plain parameter — wire it to your actual auth mechanism (e.g. `@AuthenticationPrincipal`).