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 +
@Validfor 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)