Building the core: shipments, parcels and tracking
With the domain and API designed, building the core is largely satisfying the structure you fixed. This lesson builds Dakiya's central flows — book a parcel, update its status, track it — applying the course's discipline: thin controllers, business logic in services, efficient queries, DTOs at the boundary. The patterns here are the verified ones from throughout the course, assembled into the real application.
Booking a parcel: controller, service, repository
Booking is a POST that creates a parcel. The three layers each do their job:
// controller — thin: DTO in, delegate, DTO out
@RestController
@RequestMapping("/api/v1/parcels")
public class ParcelController {
private final ParcelService service;
public ParcelController(ParcelService service) { this.service = service; } // constructor injection
@PostMapping
public ResponseEntity<ParcelResponse> book(@Valid @RequestBody BookParcelRequest req,
UriComponentsBuilder uri) {
Parcel p = service.book(req.recipientName(), req.destinationCity());
var location = uri.path("/api/v1/parcels/{id}").buildAndExpand(p.getId()).toUri();
return ResponseEntity.created(location).body(ParcelResponse.from(p)); // 201 + Location
}
}
// service — the business logic
@Service
public class ParcelService {
private final ParcelRepository repository;
public ParcelService(ParcelRepository repository) { this.repository = repository; }
@Transactional
public Parcel book(String recipientName, String destinationCity) {
Parcel p = new Parcel(generateTrackingCode(), recipientName, destinationCity);
// set the partner from the authenticated principal (secured in the next lesson)
return repository.save(p);
}
}
The controller reads the validated DTO, calls the service, returns 201 with a Location header and a
response DTO — no business logic. The service generates the tracking code, creates the parcel, and saves it,
in a transaction. This is the verified shape from the rest-apis module: a valid POST returns 201 and
creates the record; an invalid body is rejected with 400 by @Valid before the method runs.
Updating status: the lifecycle rule in the service
Status changes go through the lifecycle rule from the design lesson, in the service:
@Transactional
public Parcel updateStatus(Long id, Status target) {
Parcel p = repository.findById(id)
.orElseThrow(() -> new NotFoundException("No parcel with id " + id));
p.transitionTo(target); // rejects illegal transitions (e.g. DELIVERED -> BOOKED)
return p; // dirty checking persists the change in the transaction
}
@PostMapping("/{id}/status")
public ParcelResponse updateStatus(@PathVariable Long id, @RequestBody StatusUpdateRequest req) {
return ParcelResponse.from(service.updateStatus(id, req.status()));
}
The controller stays thin; the rule (which transitions are legal) lives in the domain/service, so it
cannot be bypassed. Note there is no explicit save() — the parcel is managed inside the transaction, so
dirty checking writes the change at commit (the persistence-context lesson). An illegal transition throws,
and the exception handler maps it to a 409/400 — the client gets a clear error, not a corrupted state.
Tracking and listing: efficient, paginated queries
Tracking one parcel is a scoped fetch; listing is paginated and free of N+1:
// list — paginated, fetch-joined to avoid N+1 on the hub, scoped to the caller (next lesson)
@GetMapping
public Page<ParcelResponse> list(@RequestParam(required = false) Status status, Pageable pageable) {
return service.list(status, pageable).map(ParcelResponse::from);
}
// repository — a fetch join so listing parcels with their hub is ONE query, not N+1
@Query("select p from Parcel p join fetch p.currentHub")
Page<Parcel> findAllWithHub(Pageable pageable);
The list endpoint is paginated (Pageable → a Page with metadata — never an unbounded list) and uses
a fetch join so showing each parcel's hub does not trigger the N+1 the course measured (1 query, not
1 + N). Tracking history (/parcels/{id}/events) reads the TrackingEvents for the parcel. Every list and
read is designed to fetch only what it needs, in as few queries as sensible — the JPA-performance
discipline applied.
Recording tracking events
As a parcel's status changes, record a TrackingEvent so the history is queryable — and do it in the same
transaction as the status change, so they are atomic:
@Transactional
public Parcel updateStatus(Long id, Status target, Hub hub) {
Parcel p = repository.findById(id).orElseThrow(() -> new NotFoundException("..."));
p.transitionTo(target);
eventRepository.save(new TrackingEvent(p, target, hub, Instant.now())); // history, same transaction
return p;
}
The status change and the history record commit together or not at all (@Transactional), so the tracking
history can never disagree with the current status — the transaction guarantees it. This is the
transactions lesson applied: two related writes, made atomic.
The core is thin controllers over rich services
Step back and see the shape: the controllers are thin (map, delegate, respond), the ParcelService holds
all the logic (booking, the transition rule, scoping, recording history, transactions), the repositories
hold the queries (fetch joins, filters, scoping), and DTOs bound the API. That is the layering the course
built towards, assembled into a working courier core. What remains is to secure it (only the right clients
can book, update, and see each parcel) and test it — the next lesson — and then to make the concurrent
status updates safe and deploy it. The core itself is now the verified patterns of the whole course, working
together.
Check your work
Booking. Thin controller (@Valid DTO → service.book → 201 + Location + response DTO); service
generates the tracking code, creates and saves the parcel in a transaction. Verified pattern: valid POST →
201/created, invalid → 400.
Status update. The lifecycle rule (transitionTo) in the domain/service rejects illegal transitions; no
explicit save() (dirty checking persists in the transaction); illegal transition → a clear error, not
corruption.
Tracking/listing. Paginated (Pageable → Page, never unbounded) and fetch-joined (one query, not
N+1); reads scoped to the caller; tracking history from TrackingEvents.
Tracking events. Recorded in the same transaction as the status change (@Transactional), so history
and current status can never disagree.
The shape. Thin controllers over rich services over query-holding repositories, DTOs at the boundary — the course's layering assembled into the courier core.
Practice
- Build the booking flow: thin controller +
@Transactionalservice; confirm a valid POST returns 201 with aLocationand an invalid one returns 400. - Implement
updateStatuscalling the lifecycletransitionTo; confirm an illegal transition (e.g. delivered → booked) is rejected with a clear error and no change. - Build the paginated list with a fetch join; with query counting confirm it is one query, not N+1, and
returns a
Page. - Record a
TrackingEventin the same transaction as a status change; force the event save to fail and confirm the status change rolls back too. - Confirm no controller contains business logic — the transition rule lives in the service/domain.
- Track a parcel's history via
/parcels/{id}/eventsand confirm it reflects the status changes.
Official documentation
- Spring — Web MVC controllers — Thin controllers, DTOs,
ResponseEntity. - Spring Data JPA — Paging and fetch joins — Paginated, N+1-free queries.
- Spring — Transactions — Atomic multi-write operations.
Next: securing and testing the API.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship