Observer Pattern in Spring Boot — Build a Real Courier Tracking Workflow with Async Audit & ETA…
In many real backend systems, one business event triggers multiple independent actions.
Observer Pattern in Spring Boot — Build a Real Courier Tracking Workflow with Async Audit & ETA Processing

In many real backend systems, one business event triggers multiple independent actions.
A courier location update may need to:
- notify the customer
- update the operations dashboard
- calculate ETA
- write an audit record
- update delivery metrics
- update courier performance score
If you place all of that logic directly inside one service method, the service becomes bloated, tightly coupled, and harder to extend.
That is where the Observer Pattern fits naturally.
In this guide, we will build a small Spring Boot project for a courier tracking workflow using the Observer Design Pattern. We will also make **AuditObserver and `EtaCalculationObserver** asynchronous with@Async`, which is a practical improvement for high-traffic production systems where non-critical side work should not slow down the main request.
1) What you’ll build
A small Spring Boot project that exposes endpoints for:
- creating a courier tracking record
- updating delivery status
- updating courier location
- fetching the current tracking state
When the tracking state changes, the system publishes an event and multiple observers react independently:
CustomerNotificationObserverOperationsObserverDeliveryMetricsObserverAuditObserverEtaCalculationObserverCourierScoreObserver
Two of them are intentionally asynchronous:
AuditObserverEtaCalculationObserver
That means the main request can finish without waiting for audit persistence and ETA calculation.
2) Why this approach?
A courier tracking flow is a strong real-life use case for Observer.
One state change can have several downstream reactions:
- business-facing: notify customer
- operational: log for support and operations teams
- analytical: update delivered order metrics
- compliance: keep an audit trail
- UX: show ETA in mobile/web app
- performance: maintain courier success score
Instead of hardcoding all of those concerns into one service, we let the service publish a tracking event and let observers react on their own.
That gives us:
- better separation of concerns
- easier extensibility
- cleaner service methods
- safer growth as requirements evolve
3) Tech Stack
- Java 25
- Spring Boot
- Spring Web
- Spring Data JPA
- PostgreSQL
@Asyncfor selected observers- Maven
4) Project Structure
courier-tracking-observer/
├─ pom.xml
└─ src/main/java/com/example/couriertracking
├─ CourierTrackingApplication.java
├─ config
│ └─ AsyncConfig.java
├─ controller
│ └─ CourierTrackingController.java
├─ dto
│ ├─ CreateTrackingRequest.java
│ ├─ UpdateCourierLocationRequest.java
│ ├─ UpdateDeliveryStatusRequest.java
│ └─ CourierTrackingResponse.java
├─ entity
│ ├─ CourierTrackingEntity.java
│ ├─ TrackingAuditEntity.java
│ └─ CourierPerformanceEntity.java
├─ enums
│ ├─ DeliveryStatus.java
│ └─ TrackingEventType.java
├─ event
│ └─ TrackingEvent.java
├─ observer
│ ├─ TrackingObserver.java
│ ├─ CustomerNotificationObserver.java
│ ├─ OperationsObserver.java
│ ├─ DeliveryMetricsObserver.java
│ ├─ AuditObserver.java
│ ├─ EtaCalculationObserver.java
│ └─ CourierScoreObserver.java
├─ repository
│ ├─ CourierTrackingRepository.java
│ ├─ TrackingAuditRepository.java
│ └─ CourierPerformanceRepository.java
├─ service
│ ├─ CourierTrackingService.java
│ └─ CourierTrackingServiceImpl.java
├─ subject
│ └─ TrackingEventPublisher.java
└─ util
└─ GeoUtils.java
5) Implementation
1️⃣ Delivery status and event type
package com.example.couriertracking.enums;
public enum DeliveryStatus {
CREATED,
ASSIGNED,
PICKED_UP,
NEAR_DESTINATION,
DELIVERED,
CANCELLED
}
package com.example.couriertracking.enums;
public enum TrackingEventType {
STATUS_CHANGED,
LOCATION_UPDATED
}
2️⃣ Tracking event snapshot
For asynchronous observers, it is safer to publish a snapshot payload instead of sharing a managed JPA entity instance.
That avoids detached entity issues and stale persistence behavior.
package com.example.couriertracking.event;
import com.example.couriertracking.enums.DeliveryStatus;
import com.example.couriertracking.enums.TrackingEventType;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.UUID;
public record TrackingEvent(
UUID trackingId,
String orderId,
String courierId,
String customerName,
TrackingEventType eventType,
DeliveryStatus deliveryStatus,
BigDecimal destinationLatitude,
BigDecimal destinationLongitude,
BigDecimal currentLatitude,
BigDecimal currentLongitude,
String message,
LocalDateTime occurredAt
) {
}
3️⃣ Observer contract
package com.example.couriertracking.observer;
import com.example.couriertracking.event.TrackingEvent;
public interface TrackingObserver {
void onTrackingChanged(TrackingEvent event);
}
4️⃣ Event publisher
Spring injects all TrackingObserver implementations into the list automatically.
package com.example.couriertracking.subject;
import com.example.couriertracking.event.TrackingEvent;
import com.example.couriertracking.observer.TrackingObserver;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
@RequiredArgsConstructor
public class TrackingEventPublisher {
private final List<TrackingObserver> observers;
public void publish(TrackingEvent event) {
for (TrackingObserver observer : observers) {
observer.onTrackingChanged(event);
}
}
}
5️⃣ Async configuration
For production, do not use the default async executor blindly. Create a dedicated executor so audit and ETA tasks do not compete with unrelated background work.
package com.example.couriertracking.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean(name = "trackingTaskExecutor")
public Executor trackingTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(8);
executor.setMaxPoolSize(16);
executor.setQueueCapacity(500);
executor.setThreadNamePrefix("tracking-async-");
executor.initialize();
return executor;
}
}
6️⃣ Core tracking entity
package com.example.couriertracking.entity;
import com.example.couriertracking.enums.DeliveryStatus;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "courier_tracking")
@Getter
@Setter
public class CourierTrackingEntity {
@Id
@GeneratedValue
private UUID id;
@Column(nullable = false)
private String orderId;
@Column(nullable = false)
private String courierId;
@Column(nullable = false)
private String customerName;
@Column(nullable = false)
private BigDecimal destinationLatitude;
@Column(nullable = false)
private BigDecimal destinationLongitude;
private BigDecimal currentLatitude;
private BigDecimal currentLongitude;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private DeliveryStatus deliveryStatus;
private Integer estimatedArrivalMinutes;
private LocalDateTime estimatedArrivalAt;
@Column(nullable = false)
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@PrePersist
public void prePersist() {
this.createdAt = LocalDateTime.now();
this.updatedAt = LocalDateTime.now();
}
@PreUpdate
public void preUpdate() {
this.updatedAt = LocalDateTime.now();
}
}
7️⃣ Audit entity
package com.example.couriertracking.entity;
import com.example.couriertracking.enums.DeliveryStatus;
import com.example.couriertracking.enums.TrackingEventType;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "tracking_audit")
@Getter
@Setter
public class TrackingAuditEntity {
@Id
@GeneratedValue
private UUID id;
@Column(nullable = false)
private UUID trackingId;
@Column(nullable = false)
private String orderId;
@Column(nullable = false)
private String courierId;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private TrackingEventType eventType;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private DeliveryStatus deliveryStatus;
@Column(nullable = false, length = 500)
private String message;
@Column(nullable = false)
private LocalDateTime occurredAt;
}
8️⃣ Courier performance entity
package com.example.couriertracking.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "courier_performance")
@Getter
@Setter
public class CourierPerformanceEntity {
@Id
@GeneratedValue
private UUID id;
@Column(nullable = false, unique = true)
private String courierId;
@Column(nullable = false)
private Integer deliveredCount = 0;
@Column(nullable = false)
private Integer cancelledCount = 0;
@Column(nullable = false)
private Integer totalScore = 0;
@Column(nullable = false)
private Double averageScore = 0.0;
@Column(nullable = false)
private LocalDateTime updatedAt;
@PrePersist
@PreUpdate
public void touch() {
this.updatedAt = LocalDateTime.now();
}
}
9️⃣ Repositories
package com.example.couriertracking.repository;
import com.example.couriertracking.entity.CourierTrackingEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;
public interface CourierTrackingRepository extends JpaRepository<CourierTrackingEntity, UUID> {
}
package com.example.couriertracking.repository;
import com.example.couriertracking.entity.TrackingAuditEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;
public interface TrackingAuditRepository extends JpaRepository<TrackingAuditEntity, UUID> {
}
package com.example.couriertracking.repository;
import com.example.couriertracking.entity.CourierPerformanceEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.UUID;
public interface CourierPerformanceRepository extends JpaRepository<CourierPerformanceEntity, UUID> {
Optional<CourierPerformanceEntity> findByCourierId(String courierId);
}
🔟 Geo utility
package com.example.couriertracking.util;
public final class GeoUtils {
private static final double EARTH_RADIUS_METERS = 6371000.0;
private GeoUtils() {
}
public static double distanceInMeters(
double lat1, double lon1,
double lat2, double lon2
) {
double latDistance = Math.toRadians(lat2 - lat1);
double lonDistance = Math.toRadians(lon2 - lon1);
double a = Math.sin(latDistance / 2) * Math.sin(latDistance / 2)
+ Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
* Math.sin(lonDistance / 2) * Math.sin(lonDistance / 2);
double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return EARTH_RADIUS_METERS * c;
}
}
1️⃣1️⃣ Service layer
This is the main business flow. The service updates the tracking record and publishes a snapshot event.
package com.example.couriertracking.service;
import com.example.couriertracking.dto.*;
import com.example.couriertracking.entity.CourierTrackingEntity;
import com.example.couriertracking.enums.DeliveryStatus;
import com.example.couriertracking.enums.TrackingEventType;
import com.example.couriertracking.event.TrackingEvent;
import com.example.couriertracking.repository.CourierTrackingRepository;
import com.example.couriertracking.subject.TrackingEventPublisher;
import com.example.couriertracking.util.GeoUtils;
import jakarta.persistence.EntityNotFoundException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.UUID;
@Service
@RequiredArgsConstructor
public class CourierTrackingServiceImpl implements CourierTrackingService {
private static final double NEAR_DESTINATION_THRESHOLD_METERS = 100.0;
private final CourierTrackingRepository repository;
private final TrackingEventPublisher trackingEventPublisher;
@Override
public CourierTrackingResponse createTracking(CreateTrackingRequest request) {
CourierTrackingEntity entity = new CourierTrackingEntity();
entity.setOrderId(request.orderId());
entity.setCourierId(request.courierId());
entity.setCustomerName(request.customerName());
entity.setDestinationLatitude(request.destinationLatitude());
entity.setDestinationLongitude(request.destinationLongitude());
entity.setDeliveryStatus(DeliveryStatus.CREATED);
CourierTrackingEntity saved = repository.save(entity);
trackingEventPublisher.publish(toEvent(
saved,
TrackingEventType.STATUS_CHANGED,
"Tracking created"
));
return mapToResponse(saved);
}
@Override
public CourierTrackingResponse updateStatus(UUID trackingId, UpdateDeliveryStatusRequest request) {
CourierTrackingEntity entity = repository.findById(trackingId)
.orElseThrow(() -> new EntityNotFoundException("Tracking not found: " + trackingId));
entity.setDeliveryStatus(request.deliveryStatus());
CourierTrackingEntity saved = repository.save(entity);
trackingEventPublisher.publish(toEvent(
saved,
TrackingEventType.STATUS_CHANGED,
"Delivery status changed to " + request.deliveryStatus()
));
return mapToResponse(saved);
}
@Override
public CourierTrackingResponse updateLocation(UUID trackingId, UpdateCourierLocationRequest request) {
CourierTrackingEntity entity = repository.findById(trackingId)
.orElseThrow(() -> new EntityNotFoundException("Tracking not found: " + trackingId));
entity.setCurrentLatitude(request.latitude());
entity.setCurrentLongitude(request.longitude());
CourierTrackingEntity saved = repository.save(entity);
trackingEventPublisher.publish(toEvent(
saved,
TrackingEventType.LOCATION_UPDATED,
"Courier location updated"
));
if (saved.getDeliveryStatus() == DeliveryStatus.PICKED_UP) {
double distance = GeoUtils.distanceInMeters(
saved.getCurrentLatitude().doubleValue(),
saved.getCurrentLongitude().doubleValue(),
saved.getDestinationLatitude().doubleValue(),
saved.getDestinationLongitude().doubleValue()
);
if (distance <= NEAR_DESTINATION_THRESHOLD_METERS) {
saved.setDeliveryStatus(DeliveryStatus.NEAR_DESTINATION);
CourierTrackingEntity nearDestination = repository.save(saved);
trackingEventPublisher.publish(toEvent(
nearDestination,
TrackingEventType.STATUS_CHANGED,
"Courier is near destination"
));
return mapToResponse(nearDestination);
}
}
return mapToResponse(saved);
}
@Override
public CourierTrackingResponse getById(UUID trackingId) {
CourierTrackingEntity entity = repository.findById(trackingId)
.orElseThrow(() -> new EntityNotFoundException("Tracking not found: " + trackingId));
return mapToResponse(entity);
}
private TrackingEvent toEvent(CourierTrackingEntity entity, TrackingEventType eventType, String message) {
return new TrackingEvent(
entity.getId(),
entity.getOrderId(),
entity.getCourierId(),
entity.getCustomerName(),
eventType,
entity.getDeliveryStatus(),
entity.getDestinationLatitude(),
entity.getDestinationLongitude(),
entity.getCurrentLatitude(),
entity.getCurrentLongitude(),
message,
LocalDateTime.now()
);
}
private CourierTrackingResponse mapToResponse(CourierTrackingEntity entity) {
return new CourierTrackingResponse(
entity.getId(),
entity.getOrderId(),
entity.getCourierId(),
entity.getCustomerName(),
entity.getDestinationLatitude(),
entity.getDestinationLongitude(),
entity.getCurrentLatitude(),
entity.getCurrentLongitude(),
entity.getDeliveryStatus(),
entity.getEstimatedArrivalMinutes(),
entity.getEstimatedArrivalAt()
);
}
}
1️⃣2️⃣ Customer notification observer
package com.example.couriertracking.observer;
import com.example.couriertracking.enums.DeliveryStatus;
import com.example.couriertracking.event.TrackingEvent;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
@Slf4j
@Component
public class CustomerNotificationObserver implements TrackingObserver {
@Override
public void onTrackingChanged(TrackingEvent event) {
DeliveryStatus status = event.deliveryStatus();
if (status == DeliveryStatus.CREATED) {
log.info("CUSTOMER NOTIFY -> OrderId: {}, your order has been created and tracking has started.",
event.orderId());
}
if (status == DeliveryStatus.NEAR_DESTINATION) {
log.info("CUSTOMER NOTIFY -> OrderId: {}, your courier is almost at the destination.",
event.orderId());
}
if (status == DeliveryStatus.DELIVERED) {
log.info("CUSTOMER NOTIFY -> OrderId: {}, your order has been delivered.",
event.orderId());
}
}
}
1️⃣3️⃣ Operations observer
package com.example.couriertracking.observer;
import com.example.couriertracking.event.TrackingEvent;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
@Slf4j
@Component
public class OperationsObserver implements TrackingObserver {
@Override
public void onTrackingChanged(TrackingEvent event) {
log.info("OPS EVENT -> type: {}, orderId: {}, courierId: {}, status: {}, message: {}",
event.eventType(),
event.orderId(),
event.courierId(),
event.deliveryStatus(),
event.message());
}
}
1️⃣4️⃣ Delivery metrics observer
package com.example.couriertracking.observer;
import com.example.couriertracking.enums.DeliveryStatus;
import com.example.couriertracking.event.TrackingEvent;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
@Slf4j
@Component
public class DeliveryMetricsObserver implements TrackingObserver {
@Override
public void onTrackingChanged(TrackingEvent event) {
if (event.deliveryStatus() == DeliveryStatus.DELIVERED) {
log.info("METRIC -> Increment delivered order counter for orderId: {}", event.orderId());
}
}
}
1️⃣5️⃣ Audit observer with @Async
This is one of the observers the user explicitly wanted to make asynchronous.
package com.example.couriertracking.observer;
import com.example.couriertracking.entity.TrackingAuditEntity;
import com.example.couriertracking.event.TrackingEvent;
import com.example.couriertracking.repository.TrackingAuditRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class AuditObserver implements TrackingObserver {
private final TrackingAuditRepository trackingAuditRepository;
@Async("trackingTaskExecutor")
@Override
public void onTrackingChanged(TrackingEvent event) {
TrackingAuditEntity audit = new TrackingAuditEntity();
audit.setTrackingId(event.trackingId());
audit.setOrderId(event.orderId());
audit.setCourierId(event.courierId());
audit.setEventType(event.eventType());
audit.setDeliveryStatus(event.deliveryStatus());
audit.setMessage(event.message());
audit.setOccurredAt(event.occurredAt());
trackingAuditRepository.save(audit);
}
}
1️⃣6️⃣ ETA observer with @Async
This is the second observer the user wanted to make asynchronous.
Notice that it loads the latest tracking row from the database instead of mutating a potentially stale object from the event payload.
package com.example.couriertracking.observer;
import com.example.couriertracking.entity.CourierTrackingEntity;
import com.example.couriertracking.enums.DeliveryStatus;
import com.example.couriertracking.enums.TrackingEventType;
import com.example.couriertracking.event.TrackingEvent;
import com.example.couriertracking.repository.CourierTrackingRepository;
import com.example.couriertracking.util.GeoUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
@Slf4j
@Component
@RequiredArgsConstructor
public class EtaCalculationObserver implements TrackingObserver {
private static final double COURIER_SPEED_METERS_PER_MINUTE = 250.0;
private final CourierTrackingRepository courierTrackingRepository;
@Async("trackingTaskExecutor")
@Override
public void onTrackingChanged(TrackingEvent event) {
if (event.eventType() != TrackingEventType.LOCATION_UPDATED) {
return;
}
if (event.currentLatitude() == null || event.currentLongitude() == null) {
return;
}
if (event.deliveryStatus() == DeliveryStatus.DELIVERED
|| event.deliveryStatus() == DeliveryStatus.CANCELLED) {
return;
}
CourierTrackingEntity tracking = courierTrackingRepository.findById(event.trackingId())
.orElse(null);
if (tracking == null) {
return;
}
double distanceInMeters = GeoUtils.distanceInMeters(
event.currentLatitude().doubleValue(),
event.currentLongitude().doubleValue(),
event.destinationLatitude().doubleValue(),
event.destinationLongitude().doubleValue()
);
int etaMinutes = (int) Math.ceil(distanceInMeters / COURIER_SPEED_METERS_PER_MINUTE);
etaMinutes = Math.max(etaMinutes, 1);
tracking.setEstimatedArrivalMinutes(etaMinutes);
tracking.setEstimatedArrivalAt(LocalDateTime.now().plusMinutes(etaMinutes));
courierTrackingRepository.save(tracking);
log.info("ETA UPDATED -> OrderId: {}, etaMinutes: {}, estimatedArrivalAt: {}",
event.orderId(),
tracking.getEstimatedArrivalMinutes(),
tracking.getEstimatedArrivalAt());
}
}
1️⃣7️⃣ Courier score observer
package com.example.couriertracking.observer;
import com.example.couriertracking.entity.CourierPerformanceEntity;
import com.example.couriertracking.enums.DeliveryStatus;
import com.example.couriertracking.event.TrackingEvent;
import com.example.couriertracking.repository.CourierPerformanceRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
@Slf4j
@Component
@RequiredArgsConstructor
public class CourierScoreObserver implements TrackingObserver {
private final CourierPerformanceRepository courierPerformanceRepository;
@Override
public void onTrackingChanged(TrackingEvent event) {
DeliveryStatus status = event.deliveryStatus();
if (status != DeliveryStatus.DELIVERED && status != DeliveryStatus.CANCELLED) {
return;
}
CourierPerformanceEntity performance = courierPerformanceRepository.findByCourierId(event.courierId())
.orElseGet(() -> {
CourierPerformanceEntity entity = new CourierPerformanceEntity();
entity.setCourierId(event.courierId());
return entity;
});
if (status == DeliveryStatus.DELIVERED) {
performance.setDeliveredCount(performance.getDeliveredCount() + 1);
performance.setTotalScore(performance.getTotalScore() + 10);
}
if (status == DeliveryStatus.CANCELLED) {
performance.setCancelledCount(performance.getCancelledCount() + 1);
performance.setTotalScore(performance.getTotalScore() - 5);
}
int total = performance.getDeliveredCount() + performance.getCancelledCount();
performance.setAverageScore(total == 0 ? 0.0 : (double) performance.getTotalScore() / total);
courierPerformanceRepository.save(performance);
log.info("COURIER SCORE UPDATED -> courierId: {}, deliveredCount: {}, cancelledCount: {}, totalScore: {}, averageScore: {}",
performance.getCourierId(),
performance.getDeliveredCount(),
performance.getCancelledCount(),
performance.getTotalScore(),
performance.getAverageScore());
}
}
1️⃣8️⃣ Controller
package com.example.couriertracking.controller;
import com.example.couriertracking.dto.CourierTrackingResponse;
import com.example.couriertracking.dto.CreateTrackingRequest;
import com.example.couriertracking.dto.UpdateCourierLocationRequest;
import com.example.couriertracking.dto.UpdateDeliveryStatusRequest;
import com.example.couriertracking.service.CourierTrackingService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.UUID;
@RestController
@RequestMapping("/api/v1/courier-tracking")
@RequiredArgsConstructor
public class CourierTrackingController {
private final CourierTrackingService courierTrackingService;
@PostMapping
public CourierTrackingResponse createTracking(@Valid @RequestBody CreateTrackingRequest request) {
return courierTrackingService.createTracking(request);
}
@PutMapping("/{trackingId}/status")
public CourierTrackingResponse updateStatus(
@PathVariable UUID trackingId,
@Valid @RequestBody UpdateDeliveryStatusRequest request
) {
return courierTrackingService.updateStatus(trackingId, request);
}
@PutMapping("/{trackingId}/location")
public CourierTrackingResponse updateLocation(
@PathVariable UUID trackingId,
@Valid @RequestBody UpdateCourierLocationRequest request
) {
return courierTrackingService.updateLocation(trackingId, request);
}
@GetMapping("/{trackingId}")
public CourierTrackingResponse getById(@PathVariable UUID trackingId) {
return courierTrackingService.getById(trackingId);
}
}
6) End-to-end scenario
Let’s walk through one order from start to finish.
Example:
orderId = ORDER-1001courierId = COURIER-77customerName = Noyan
Step 1 — Create tracking
POST /api/v1/courier-tracking
Content-Type: application/json
{
"orderId": "ORDER-1001",
"courierId": "COURIER-77",
"customerName": "Noyan",
"destinationLatitude": 41.0082,
"destinationLongitude": 28.9784
}
What happens:
- tracking row is created
- status becomes
CREATED - event is published
- customer notification runs
- operations log runs
- audit row is written asynchronously
Sample logs
INFO --- [http-nio-8080-exec-1] c.e.c.observer.OperationsObserver :
OPS EVENT -> type: STATUS_CHANGED, orderId: ORDER-1001, courierId: COURIER-77, status: CREATED, message: Tracking created
INFO --- [http-nio-8080-exec-1] c.e.c.observer.CustomerNotificationObserver :
CUSTOMER NOTIFY -> OrderId: ORDER-1001, your order has been created and tracking has started.
INFO --- [tracking-async-1] c.e.c.observer.AuditObserver :
AUDIT SAVED -> trackingId: 2d1f7c61-0f93-4d1d-a7ae-1a8f71d9b201, orderId: ORDER-1001, eventType: STATUS_CHANGED, status: CREATED, message: Tracking created
Step 2 — Assign courier
PUT /api/v1/courier-tracking/{trackingId}/status
{
"deliveryStatus": "ASSIGNED"
}
What happens:
- status becomes
ASSIGNED - event is published
- operations log runs
- audit row is written asynchronously
Sample logs
INFO --- [http-nio-8080-exec-2] c.e.c.observer.OperationsObserver :
OPS EVENT -> type: STATUS_CHANGED, orderId: ORDER-1001, courierId: COURIER-77, status: ASSIGNED, message: Delivery status changed to ASSIGNED
INFO --- [tracking-async-2] c.e.c.observer.AuditObserver :
AUDIT SAVED -> trackingId: 2d1f7c61-0f93-4d1d-a7ae-1a8f71d9b201, orderId: ORDER-1001, eventType: STATUS_CHANGED, status: ASSIGNED, message: Delivery status changed to ASSIGNED
Step 3 — Courier picks up package
PUT /api/v1/courier-tracking/{trackingId}/status
{
"deliveryStatus": "PICKED_UP"
}
What happens:
- status becomes
PICKED_UP - event is published
- operations log runs
- audit row is written asynchronously
Sample logs
INFO --- [http-nio-8080-exec-3] c.e.c.observer.OperationsObserver :
OPS EVENT -> type: STATUS_CHANGED, orderId: ORDER-1001, courierId: COURIER-77, status: PICKED_UP, message: Delivery status changed to PICKED_UP
INFO --- [tracking-async-3] c.e.c.observer.AuditObserver :
AUDIT SAVED -> trackingId: 2d1f7c61-0f93-4d1d-a7ae-1a8f71d9b201, orderId: ORDER-1001, eventType: STATUS_CHANGED, status: PICKED_UP, message: Delivery status changed to PICKED_UP
Step 4 — First location update
PUT /api/v1/courier-tracking/{trackingId}/location
{
"latitude": 41.0150,
"longitude": 28.9900
}
What happens:
- location is stored
LOCATION_UPDATEDevent is published- operations log runs
- audit row is written asynchronously
- ETA is calculated asynchronously
Example result:
- ETA = 7 minutes
estimatedArrivalAtis updated in the database
Sample logs
INFO --- [http-nio-8080-exec-4] c.e.c.observer.OperationsObserver :
OPS EVENT -> type: LOCATION_UPDATED, orderId: ORDER-1001, courierId: COURIER-77, status: PICKED_UP, message: Courier location updated
INFO --- [tracking-async-4] c.e.c.observer.AuditObserver :
AUDIT SAVED -> trackingId: 2d1f7c61-0f93-4d1d-a7ae-1a8f71d9b201, orderId: ORDER-1001, eventType: LOCATION_UPDATED, status: PICKED_UP, message: Courier location updated
INFO --- [tracking-async-5] c.e.c.observer.EtaCalculationObserver :
ETA UPDATED -> OrderId: ORDER-1001, etaMinutes: 7, estimatedArrivalAt: 2026-04-24T14:37:00
Step 5 — Courier gets near destination
PUT /api/v1/courier-tracking/{trackingId}/location
{
"latitude": 41.0081,
"longitude": 28.9783
}
What happens:
- location is stored
LOCATION_UPDATEDevent is published- ETA is recalculated asynchronously
- distance threshold is checked
- if distance is 100 meters or less, status changes to
NEAR_DESTINATION - customer gets near-destination notification
- operations log runs
- audit row is written asynchronously
Example result:
- ETA = 1 minute
- customer sees “Your courier is almost at the destination”
Sample logs
INFO --- [http-nio-8080-exec-5] c.e.c.observer.OperationsObserver :
OPS EVENT -> type: LOCATION_UPDATED, orderId: ORDER-1001, courierId: COURIER-77, status: PICKED_UP, message: Courier location updated
INFO --- [tracking-async-6] c.e.c.observer.AuditObserver :
AUDIT SAVED -> trackingId: 2d1f7c61-0f93-4d1d-a7ae-1a8f71d9b201, orderId: ORDER-1001, eventType: LOCATION_UPDATED, status: PICKED_UP, message: Courier location updated
INFO --- [tracking-async-7] c.e.c.observer.EtaCalculationObserver :
ETA UPDATED -> OrderId: ORDER-1001, etaMinutes: 1, estimatedArrivalAt: 2026-04-24T14:31:00
INFO --- [http-nio-8080-exec-5] c.e.c.observer.OperationsObserver :
OPS EVENT -> type: STATUS_CHANGED, orderId: ORDER-1001, courierId: COURIER-77, status: NEAR_DESTINATION, message: Courier is near destination
INFO --- [http-nio-8080-exec-5] c.e.c.observer.CustomerNotificationObserver :
CUSTOMER NOTIFY -> OrderId: ORDER-1001, your courier is almost at the destination.
INFO --- [tracking-async-8] c.e.c.observer.AuditObserver :
AUDIT SAVED -> trackingId: 2d1f7c61-0f93-4d1d-a7ae-1a8f71d9b201, orderId: ORDER-1001, eventType: STATUS_CHANGED, status: NEAR_DESTINATION, message: Courier is near destination
Step 6 — Delivery completed
PUT /api/v1/courier-tracking/{trackingId}/status
{
"deliveryStatus": "DELIVERED"
}
What happens:
- status becomes
DELIVERED - customer gets delivered notification
- delivered metric is incremented
- courier score is updated
- operations log runs
- audit row is written asynchronously
Example courier score result:
- delivered count = 1
- total score = 10
- average score = 10.0
Sample logs
INFO --- [http-nio-8080-exec-6] c.e.c.observer.OperationsObserver :
OPS EVENT -> type: STATUS_CHANGED, orderId: ORDER-1001, courierId: COURIER-77, status: DELIVERED, message: Delivery status changed to DELIVERED
INFO --- [http-nio-8080-exec-6] c.e.c.observer.CustomerNotificationObserver :
CUSTOMER NOTIFY -> OrderId: ORDER-1001, your order has been delivered.
INFO --- [http-nio-8080-exec-6] c.e.c.observer.DeliveryMetricsObserver :
METRIC -> Increment delivered order counter for orderId: ORDER-1001
INFO --- [http-nio-8080-exec-6] c.e.c.observer.CourierScoreObserver :
COURIER SCORE UPDATED -> courierId: COURIER-77, deliveredCount: 1, cancelledCount: 0, totalScore: 10, averageScore: 10.0
INFO --- [tracking-async-9] c.e.c.observer.AuditObserver :
AUDIT SAVED -> trackingId: 2d1f7c61-0f93-4d1d-a7ae-1a8f71d9b201, orderId: ORDER-1001, eventType: STATUS_CHANGED, status: DELIVERED, message: Delivery status changed to DELIVERED
🎯 Conclusion
The Observer Pattern is a natural fit for courier tracking systems.
A single business event — like a location update or delivery completion — often needs to trigger many independent actions. By publishing a tracking event and letting observers react independently, we keep the service layer small, extensible, and easier to maintain.
Adding @Async to AuditObserver and EtaCalculationObserver is also a practical production-minded enhancement:
- audit persistence no longer blocks the request
- ETA calculation becomes background work
- the main tracking API remains focused on the core business action
That gives you a design that is:
- clean
- scalable
- realistic
- ready to evolve
Happy Coding!
🤝Connect with Me
- GitHub: My Github Profile
- LinkedIn: My Linkedln Profile
- Stackoverflow: My Stackoverflow Profile
메타데이터
- post_id
- b0ba07ccc34e
- slug
- observer-pattern-in-spring-boot-build-a-real-courier-tracking-workflow-with-async-audit-eta-b0ba07ccc34e
- url
- https://medium.com/@sngermiyanoglu/observer-pattern-in-spring-boot-build-a-real-courier-tracking-workflow-with-async-audit-eta-b0ba07ccc34e
- canonical_url
- https://medium.com/@sngermiyanoglu/observer-pattern-in-spring-boot-build-a-real-courier-tracking-workflow-with-async-audit-eta-b0ba07ccc34e
- author_url
- https://medium.com/@sngermiyanoglu
- status
- ok
- fetched_at
- 2026-08-06 10:49:58