← Back to list

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.

Noyan Germiyanoğlu · 2026-04-24 09:18 · 47 claps · 11.4 min read
#spring-boot #design-patterns #java #observer-pattern #observer-design-pattern
Open on Medium ↗
Wiki topics: 🌐 · Web Development

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:

  • CustomerNotificationObserver
  • OperationsObserver
  • DeliveryMetricsObserver
  • AuditObserver
  • EtaCalculationObserver
  • CourierScoreObserver

Two of them are intentionally asynchronous:

  • AuditObserver
  • EtaCalculationObserver

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
  • @Async for 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-1001
  • courierId = COURIER-77
  • customerName = 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_UPDATED event is published
  • operations log runs
  • audit row is written asynchronously
  • ETA is calculated asynchronously

Example result:

  • ETA = 7 minutes
  • estimatedArrivalAt is 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_UPDATED event 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


메타데이터
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