In Phase 1, we built core CRUD APIs and statistics endpoints.
In Phase 2, we introduced real-time WebSocket updates so clients see shipment changes instantly.
Phase 3 is about refactoring and hardening the backend:
- Adding a Service Layer to cleanly separate logic
- Centralizing error handling with
@ControllerAdvice
- Using DTOs +
@Valid
for safe and validated input - Writing unit and integration tests
- General cleanup to prepare for production
π§ Why Refactor?
As features grow, putting everything in controllers (like we did earlier) becomes messy. Controllers shouldn't know about database details or WebSocket internals. Instead, they should delegate to a service layer. This also makes testing easier and avoids duplication.
1οΈβ£ Service Layer
Why create a service interface?
- Defines a contract β what operations are available
- Easier to test β we can mock the service in controller tests
- Future flexibility β switch implementations without breaking controllers
ShipmentService.java
public interface ShipmentService {
Shipment createShipment(CreateShipmentRequest request);
Shipment getShipmentByIdOrThrow(Long id);
List<Shipment> getAllShipments();
Shipment updateShipment(Long id, UpdateShipmentRequest request);
void deleteShipment(Long id);
Page<Shipment> searchShipments(String origin, ShipmentStatus status, Pageable pageable);
ShipmentStatsDTO getShipmentStats();
}
ShipmentServiceImpl.java
@Service
public class ShipmentServiceImpl implements ShipmentService {
@Autowired
private ShipmentRepository shipmentRepository;
@Autowired
private ShipmentStatusBroadcaster broadcaster;
@Override
public Shipment createShipment(CreateShipmentRequest request) {
Shipment shipment = new Shipment();
shipment.setOrigin(request.getOrigin());
shipment.setDestination(request.getDestination());
shipment.setStatus(request.getStatus());
shipment.setTrackingNumber(request.getTrackingNumber());
shipment.setCarrier(request.getCarrier());
shipment.setPriority(request.getPriority());
shipment.setLastUpdatedTime(LocalDateTime.now());
Shipment saved = shipmentRepository.save(shipment);
broadcaster.broadcastUpdate(
ShipmentUpdateMessage.builder()
.shipmentId(saved.getId())
.trackingNumber(saved.getTrackingNumber())
.status(saved.getStatus())
.lastUpdatedTime(format(saved.getLastUpdatedTime()))
.build()
);
return saved;
}
// Other methods like updateShipment, getShipmentByIdOrThrow, etc.
}
Key takeaway:
- Controller only handles routing and leaves all logic to service
- Service calls repository and broadcaster, making code modular and testable
2οΈβ£ Centralized Error Handling
Before: Controllers used try/catch blocks or returned ResponseEntity.notFound()
directly. This caused repetition and inconsistent error messages.
After: We created a global handler using @ControllerAdvice
:
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ShipmentNotFoundException.class)
public ResponseEntity<Map<String, Object>> handleNotFound(ShipmentNotFoundException ex) {
Map<String, Object> errorBody = new HashMap<>();
errorBody.put("status", "error");
errorBody.put("message", ex.getMessage());
errorBody.put("timestamp", LocalDateTime.now());
return new ResponseEntity<>(errorBody, HttpStatus.NOT_FOUND);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidationErrors(MethodArgumentNotValidException ex) {
Map<String, String> fieldErrors = ex.getBindingResult().getFieldErrors().stream()
.collect(Collectors.toMap(FieldError::getField, FieldError::getDefaultMessage));
Map<String, Object> response = new HashMap<>();
response.put("status", "error");
response.put("errors", fieldErrors);
response.put("timestamp", LocalDateTime.now());
return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
}
}
Why this is better:
- One place handles all exceptions
- Clean controllers (no try/catch spam)
- Consistent JSON error format for frontend
3οΈβ£ DTOs + @valid
DTO (Data Transfer Object)
- Controls what fields the client can send/receive
- Prevents exposing internal database fields (id, lastUpdatedTime)
- Allows different validation rules for create vs update requests
Example: CreateShipmentRequest
@Data
public class CreateShipmentRequest {
@NotBlank(message = "Origin is required")
private String origin;
@NotBlank(message = "Destination is required")
private String destination;
@NotNull(message = "Status is required")
private ShipmentStatus status;
@NotBlank(message = "Tracking number is required")
@Size(min = 5, message = "Tracking number must be at least 5 characters long")
private String trackingNumber;
private String carrier;
private Priority priority;
}
@valid
- Automatically validates DTO fields before calling the service
- If invalid, throws
MethodArgumentNotValidException
(caught by our global handler) - Removes the need for manual if checks in controllers
Difference Between DTO and @valid
- DTO: Shapes the data (what fields are allowed)
- @valid: Enforces rules on that data (what values are allowed)
Together:
- DTO ensures we only accept specific fields
- @valid ensures those fields have valid values
4οΈβ£ Testing
We wrote unit tests for the service layer and integration tests for the controller.
Unit Test Example (Service Layer)
@Test
void testCreateShipment() {
// Simulate DB behavior
when(shipmentRepository.save(any(Shipment.class))).thenAnswer(invocation -> {
Shipment s = invocation.getArgument(0);
s.setId(1L); // Simulate DB assigning ID
return s;
});
CreateShipmentRequest req = new CreateShipmentRequest();
req.setOrigin("NY");
req.setDestination("Chicago");
req.setStatus(ShipmentStatus.IN_TRANSIT);
req.setTrackingNumber("TRK12345");
Shipment result = shipmentService.createShipment(req);
assertEquals("NY", result.getOrigin()); // Verify service mapped correctly
verify(broadcaster, times(1)).broadcastUpdate(any()); // WebSocket should be triggered
}
Integration Test Example (Controller + H2 DB)
@Test
void createShipment_andGetAllShipments() throws Exception {
CreateShipmentRequest request = new CreateShipmentRequest();
request.setOrigin("New York");
request.setDestination("Chicago");
request.setStatus(ShipmentStatus.IN_TRANSIT);
request.setTrackingNumber("TRK12345");
// POST request
mockMvc.perform(post("/api/shipments")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpected(status().isOk())
.andExpected(jsonPath("$.origin").value("New York"));
// GET request
mockMvc.perform(get("/api/shipments"))
.andExpected(status().isOk())
.andExpected(jsonPath("$[0].origin").value("New York"));
}
Why both Unit and Integration?
- Unit tests check business logic in isolation (fast, targeted)
- Integration tests verify the full request β controller β service β DB flow
- Both together give confidence: logic works and wiring works
ποΈ What's Next?
- Frontend (React): Build a dashboard for clients and admins
- Authentication: Basic login for admins to manage shipments
- Cloud Deployment: Docker + AWS (EC2 or RDS)
- Advanced Features: More analytics, filters, and push notifications
π©βπ» Final Thoughts
Phase 3 transformed our backend from a basic prototype into a clean, testable, production-ready API. By introducing service layers, DTOs, validations, and tests, we've laid the foundation for scaling features without chaos.
You can check the repo here: https://github.com/jayanti-neu/freight-tracker
Top comments (0)