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

Designing the domain and the API

In a Spring application, two things are the architecture: the domain model (the entities and their relationships) and the API contract (the endpoints and DTOs). Get both right before writing logic and the implementation largely falls out; get them wrong and you fight them forever. This lesson designs Dakiya's domain and API, with the reasoning behind each choice, so you are designing deliberately.

The domain model

Dakiya's entities, with deliberate field and relationship choices (the JPA modules):

@Entity
public class Parcel {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String trackingCode;                  // unique — a UNIQUE constraint enforces it

    private String recipientName;
    private String destinationCity;

    @Enumerated(EnumType.STRING)                  // STRING, never ORDINAL
    private Status status = Status.BOOKED;

    @ManyToOne(fetch = FetchType.LAZY)            // LAZY by default
    private Hub currentHub;

    @ManyToOne(fetch = FetchType.LAZY)
    private Partner partner;                       // who booked it — the basis for object-level access

    @Version
    private Long version;                          // optimistic locking for concurrent status updates

    public enum Status { BOOKED, IN_TRANSIT, DELIVERED, CANCELLED }
}

Every choice is a course decision: trackingCode unique with a database constraint (not just an app check — race-safe); the status enum @Enumerated(STRING) (ordinal corrupts on reordering); relationships @ManyToOne(LAZY) (fetch on demand, no N+1 by default); a partner link that will drive object-level access (a partner sees only parcels where partner == them); and a @Version field because parcel status is updated concurrently by riders — the lost-update fix, designed in from the start. Hub, Rider and Partner are similar entities; a TrackingEvent (parcel, status, timestamp, hub) records the history.

Model the status lifecycle as rules, not just a field

A parcel's status is not a free field — it follows a lifecycle with legal transitions: booked → in transit → delivered; booked → cancelled; but not delivered → booked. Model this as a rule in the domain/service, so an illegal transition is rejected:

public void transitionTo(Status target) {
    if (!status.canTransitionTo(target)) {
        throw new IllegalTransitionException(status + " cannot become " + target);
    }
    this.status = target;
}

Encoding the legal transitions (on the enum or in a service) means the business rule — how a parcel's status may change — lives in one place and cannot be violated by any endpoint. This is "make illegal states hard to reach" applied to a lifecycle, and it is exactly the kind of domain logic that belongs in the service layer, not scattered across controllers.

The API contract

Design the endpoints resource-first (the api-design lesson), versioned, with DTOs:

POST   /api/v1/parcels                 book a parcel            → 201 + Location
GET    /api/v1/parcels/{id}            track one parcel         → 200 (scoped to caller)
GET    /api/v1/parcels?status=&page=   list/filter parcels      → 200 (paginated, scoped)
POST   /api/v1/parcels/{id}/status     update status (staff)    → 200
GET    /api/v1/parcels/{id}/events     tracking history         → 200

Note the design decisions: plural noun resources (/parcels), HTTP methods as verbs (POST to book, GET to track), a version prefix (/api/v1), status as a sub-resource action (/parcels/{id}/status — a status change is not a generic PUT of the whole parcel), pagination on the list, and — implicit but essential — every read scoped to the caller (a partner's list and tracking return only their parcels). Each endpoint takes and returns DTOs, never entities:

public record BookParcelRequest(
    @NotBlank String recipientName,
    @NotBlank @Size(min = 2, max = 60) String destinationCity
) {}

public record ParcelResponse(Long id, String trackingCode, String recipientName,
                             String destinationCity, String status) { /* from(entity) */ }

The request DTO exposes only what a client may set (not status, trackingCode, id — the server assigns those); the response DTO exposes only what the API publishes. This is the contract clients depend on, and keeping it separate from the entities lets the schema evolve without breaking clients.

The design dictates the layers

With the model and contract fixed, the layering (the best-practices lesson) is determined:

  • Controllers — one per resource area (ParcelController), thin: map request DTO → service call → response DTO, with the right status.
  • Services — ParcelService holds the business logic: booking (generate tracking code, set partner), the status-transition rule, scoping reads to the caller. @Transactional where writes are grouped.
  • Repositories — ParcelRepository with the queries: findByPartner (for scoping), status filters, fetch joins for list views.

So the design lesson produces not just data but the shape of the whole application — which controllers, which services, which queries — before a line of logic. That is the point: in Spring, the domain model plus the API contract are the architecture; design them well and the build (next lesson) is largely satisfying a structure you have already fixed.

Check your work

The domain model. Entities with deliberate choices: unique trackingCode (DB constraint, race-safe), @Enumerated(STRING), @ManyToOne(LAZY), a partner link (for object-level access), and a @Version field (concurrent status updates — lost-update fix designed in). Plus Hub/Rider/Partner and a TrackingEvent history.

Status lifecycle as rules. Legal transitions (booked→in transit→delivered; not delivered→booked) encoded in the domain/service so illegal transitions are rejected — business logic in one place, not scattered.

The API contract. Resource-first, versioned (/api/v1/parcels), HTTP methods as verbs, status as a sub-resource action, paginated lists, every read scoped to the caller; DTOs at the boundary (request = client-settable only; response = published only), never entities.

Design dictates layers. Thin controllers (DTO↔service↔response), services holding logic (booking, transitions, scoping, transactions), repositories with the queries (partner scoping, filters, fetch joins) — the whole app's shape fixed before logic.

Practice

  1. Write the Parcel entity with the unique tracking code, STRING enum, LAZY relationships, partner link, and @Version; add Hub, Partner, and a TrackingEvent.
  2. Encode the status lifecycle (legal transitions) and a transitionTo that rejects illegal ones; test an illegal transition throws.
  3. Design the full endpoint list (resource-first, versioned, paginated, scoped) and the request/response DTOs; justify each design decision.
  4. Confirm the request DTO cannot set status/trackingCode/id, and the response DTO exposes only published fields.
  5. From the model and contract, list the controllers, service methods, and repository queries the app will need — before writing logic.
  6. Compare this design to your paper sketch from the brief: where did you miss a constraint, a scoping rule, or the @Version?

Official documentation

Next: building the core — shipments, parcels and tracking.

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