DEV Community

jayanti-neu
jayanti-neu

Posted on

🚚 Building a Real-Time Freight Tracker in Java (Phase 3)

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();
}
Enter fullscreen mode Exit fullscreen mode

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.
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

@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
}
Enter fullscreen mode Exit fullscreen mode

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"));
}
Enter fullscreen mode Exit fullscreen mode

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)