RizTech Academy logo
RizTech Academy
Capstone: Dakiya, end to endLesson 3 of 545 min

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

  1. Build the booking flow: thin controller + @Transactional service; confirm a valid POST returns 201 with a Location and an invalid one returns 400.
  2. Implement updateStatus calling the lifecycle transitionTo; confirm an illegal transition (e.g. delivered → booked) is rejected with a clear error and no change.
  3. Build the paginated list with a fetch join; with query counting confirm it is one query, not N+1, and returns a Page.
  4. Record a TrackingEvent in the same transaction as a status change; force the event save to fail and confirm the status change rolls back too.
  5. Confirm no controller contains business logic — the transition rule lives in the service/domain.
  6. Track a parcel's history via /parcels/{id}/events and confirm it reflects the status changes.

Official documentation

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