Response
In a layered Spring Boot architecture, **the Service layer owns business rules, invariants, and error boundaries**, rather than delegating rule enforcement to repository query filters (which would conflate permission violations with non-existent entities).
### Architectural Overview
1. **Existence Verification**: Queries `InvoiceRepository.findById(invoiceId)`. If absent, raises `InvoiceNotFoundException` (mapped to **404 Not Found**).
2. **Ownership Enforcement**: Validates that `invoice.customerId() == customerId`. If mismatched, raises `InvoiceAccessDeniedException` (mapped to **403 Forbidden**).
3. **Lifecycle / State Enforcement**: Validates that the invoice is still `UNPAID` (`invoice.status() == InvoiceStatus.UNPAID`). If not, raises `InvoiceNotUnpaidException` (mapped to **409 Conflict**).
4. **State Transition**: Invokes `invoice.markCancelled()` within transaction boundaries (`@Transactional`).
5. **API Error Response Translation**: An `@RestControllerAdvice` translates domain exceptions into standard RFC 7807 `ProblemDetail` responses, while `@ResponseStatus` annotations provide direct fallback status mapping.
---
### 1. `InvoiceService.java`
```java
package com.acme.billing;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class InvoiceService {
private final InvoiceRepository invoiceRepository;
public InvoiceService(InvoiceRepository invoiceRepository) {
this.invoiceRepository = invoiceRepository;
}
/**
* Cancels an invoice if the requesting customer is the owner and the invoice is still unpaid.
*
* @param invoiceId the ID of the invoice to cancel
* @param customerId the ID of the customer requesting cancellation
* @throws InvoiceNotFoundException if the invoice does not exist
* @throws InvoiceAccessDeniedException if the invoice does not belong to the requesting customer
* @throws InvoiceNotUnpaidException if the invoice is not in UNPAID status
*/
@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, customerId);
}
if (invoice.status() != InvoiceStatus.UNPAID) {
throw new InvoiceNotUnpaidException(invoiceId, invoice.status());
}
invoice.markCancelled();
}
/**
* Convenience alias for cancelInvoice.
*/
@Transactional
public void cancel(long invoiceId, long customerId) {
cancelInvoice(invoiceId, customerId);
}
}
```
---
### 2. Domain Exceptions
#### `InvoiceNotFoundException.java`
```java
package com.acme.billing;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.NOT_FOUND)
public class InvoiceNotFoundException extends RuntimeException {
private final long invoiceId;
public InvoiceNotFoundException(long invoiceId) {
super("Invoice not found with id: " + invoiceId);
this.invoiceId = invoiceId;
}
public long getInvoiceId() {
return invoiceId;
}
}
```
#### `InvoiceAccessDeniedException.java`
```java
package com.acme.billing;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.FORBIDDEN)
public class InvoiceAccessDeniedException extends RuntimeException {
private final long invoiceId;
private final long customerId;
public InvoiceAccessDeniedException(long invoiceId, long customerId) {
super(String.format("Customer %d is not authorized to cancel invoice %d", customerId, invoiceId));
this.invoiceId = invoiceId;
this.customerId = customerId;
}
public long getInvoiceId() {
return invoiceId;
}
public long getCustomerId() {
return customerId;
}
}
```
#### `InvoiceNotUnpaidException.java`
```java
package com.acme.billing;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.CONFLICT)
public class InvoiceNotUnpaidException extends IllegalStateException {
private final long invoiceId;
private final InvoiceStatus currentStatus;
public InvoiceNotUnpaidException(long invoiceId, InvoiceStatus currentStatus) {
super(String.format("Invoice %d cannot be cancelled because its status is %s (must be UNPAID)", invoiceId, currentStatus));
this.invoiceId = invoiceId;
this.currentStatus = currentStatus;
}
public long getInvoiceId() {
return invoiceId;
}
public InvoiceStatus getCurrentStatus() {
return currentStatus;
}
}
```
---
### 3. `InvoiceExceptionHandler.java` (Global Error Response Handler)
```java
package com.acme.billing;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.net.URI;
import java.time.Instant;
@RestControllerAdvice
public class InvoiceExceptionHandler {
@ExceptionHandler(InvoiceNotFoundException.class)
public ProblemDetail handleInvoiceNotFound(InvoiceNotFoundException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
problem.setTitle("Invoice Not Found");
problem.setType(URI.create("urn:problem-type:invoice-not-found"));
problem.setProperty("timestamp", Instant.now());
problem.setProperty("invoiceId", ex.getInvoiceId());
return problem;
}
@ExceptionHandler(InvoiceAccessDeniedException.class)
public ProblemDetail handleInvoiceAccessDenied(InvoiceAccessDeniedException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.FORBIDDEN, ex.getMessage());
problem.setTitle("Invoice Access Denied");
problem.setType(URI.create("urn:problem-type:invoice-access-denied"));
problem.setProperty("timestamp", Instant.now());
problem.setProperty("invoiceId", ex.getInvoiceId());
problem.setProperty("customerId", ex.getCustomerId());
return problem;
}
@ExceptionHandler(InvoiceNotUnpaidException.class)
public ProblemDetail handleInvoiceNotUnpaid(InvoiceNotUnpaidException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
problem.setTitle("Invoice Not Unpaid");
problem.setType(URI.create("urn:problem-type:invoice-not-unpaid"));
problem.setProperty("timestamp", Instant.now());
problem.setProperty("invoiceId", ex.getInvoiceId());
problem.setProperty("currentStatus", ex.getCurrentStatus());
return problem;
}
@ExceptionHandler(IllegalStateException.class)
public ProblemDetail handleIllegalState(IllegalStateException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
problem.setTitle("Conflict");
problem.setType(URI.create("urn:problem-type:conflict"));
problem.setProperty("timestamp", Instant.now());
return problem;
}
}
```
---
### 4. `InvoiceController.java` (REST API Endpoint)
```java
package com.acme.billing;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/invoices")
public class InvoiceController {
private final InvoiceService invoiceService;
public InvoiceController(InvoiceService invoiceService) {
this.invoiceService = invoiceService;
}
@PostMapping("/{id}/cancel")
public ResponseEntity<Void> cancelInvoice(
@PathVariable("id") long id,
@RequestHeader("X-Customer-Id") long customerId) {
invoiceService.cancelInvoice(id, customerId);
return ResponseEntity.noContent().build();
}
}
```
---
### 5. Supporting Contracts
#### `InvoiceStatus.java`
```java
package com.acme.billing;
public enum InvoiceStatus {
UNPAID,
PAID,
CANCELLED
}
```
#### `Invoice.java`
```java
package com.acme.billing;
public interface Invoice {
long customerId();
InvoiceStatus status();
void markCancelled();
}
```
#### `InvoiceRepository.java`
```java
package com.acme.billing;
import java.util.Optional;
public interface InvoiceRepository {
Optional<Invoice> findById(long id);
}
```