We have spent three lessons pointing at the same debt. In 03-02, an exercise showed that exposing the record Station directly forces us to pollute it with Jackson annotations and to maintain a fragile deny list. In 03-03 we had to invent NewStation because the client cannot send the id. In 03-04 we renamed it to CreateStationRequest and hung the validation constraints on it. The project already has two representations of a station coexisting without anyone having organised that coexistence. This lesson organises it: it separates the domain —what CicloUrbana knows about the Ribalta network— from the contract —what the API promises its clients—, designs the project's complete DTO hierarchy and compares the strategies for moving data from one layer to another without writing repetitive code or introducing silent errors.

Contents

  1. Why domain entities are not exposed
  2. Four concrete failures, with examples
  3. DTO types: request and response
  4. Why immutable records
  5. CicloUrbana's DTO hierarchy
  6. Manual mapping
  7. MapStruct
  8. ModelMapper and why the course rules it out
  9. Where the mapping lives
  10. Projections and partial responses
  11. Nested DTOs and aggregates
  12. Evolving the contract without breaking clients
  13. Common Mistakes and Tips
  14. Exercises

  1. Why domain entities are not exposed

A DTO (Data Transfer Object) is an object whose only purpose is to carry data across a boundary. It has no behaviour, no rules and it does not live in the domain: it belongs to the contract.

The temptation to skip them is strong. Returning Station directly saves a class, a mapping and a line in the controller. Multiplied by twenty endpoints, that looks like a lot of saving. What you save in the first month you pay back with interest from the third onwards, for four reasons:

Problem What happens When it shows up
Coupling Renaming a Java field changes the JSON and breaks clients On the first refactor
Data leaks A new field is published without anyone deciding it On adding any field
Circular references Serialisation goes into an infinite loop On modelling relationships (module 4)
Blocked evolution Domain and contract cannot change separately When the business evolves

The underlying argument is one of design: the domain model and the public contract change for different reasons and at different rates. The domain changes when Ribalta's business changes; the contract changes when you negotiate it with the client teams. Tying them together makes every change in one drag the other along.

  1. Four concrete failures, with examples

Failure 1: the data leak. It is the most serious and the most silent. Suppose that in module 5 we add authentication and the record User(Long id, String name, String email, String passwordHash, String nationalId, LocalDate signupDate) grows those two last sensitive fields. If UserController returns User, the response of GET /api/v1/users/1 includes the password hash and the national id of a Ribalta citizen. Nobody decided that: it happened because the default mechanism is to publish. It can be papered over with @JsonIgnore, but that is a deny list, and deny lists fail by omission: the day a field is added and nobody remembers to annotate it, it gets published. A DTO is an allow list: only what you enumerate goes out, and forgetting produces at worst a missing field, spotted instantly.

Failure 2: the invisible coupling. The team decides that capacity should be called totalSpaces because it is clearer in the domain. It is a two-second refactor in the IDE. And it breaks the mobile app of every Ribalta citizen who already has it installed, because the JSON goes from "capacity" to "totalSpaces" with nobody noticing.

Failure 3: circular references. In module 4, Station will have a list of Bike and each Bike a reference to its Station. On serialising, the walk Station 1 → bikes → Bike 42 → station → Station 1 → ... produces a StackOverflowError or a response several megabytes long. It can be patched with @JsonManagedReference and @JsonBackReference, but that means putting serialisation decisions inside the data model. With DTOs the problem does not exist: StationDetailResponse contains BikeSummary, and BikeSummary does not contain the station.

Failure 4: blocked evolution. The council asks the API to expose availableBikes, a piece of data that is not in the entity because it is computed by counting bikes. Without a DTO there are two bad ways out: adding a computed field to the domain entity —polluting it with presentation needs— or returning an untyped Map. With a DTO it is trivial: StationResponse has that field and the mapper fills it in.

graph LR
    subgraph Contract["Public contract (API)"]
        RQ["CreateStationRequest"]
        RS["StationResponse"]
    end
    subgraph Domain["Domain (CicloUrbana)"]
        E["Station"] --- S["StationService"]
    end
    RQ -->|maps to| E
    E -->|maps to| RS

  1. DTO types: request and response

DTOs fall into two families with different rules.

Request DTOs: what the client sends. They carry no server-generated identifiers —the id goes in the path, not in the body—, they carry no derived fields —a rental's amount is computed by the server—, they carry the validation constraints from 03-04 and they are minimal: the fewer fields the API accepts, the smaller the attack surface.

Response DTOs: what the server returns. They carry no validation —nobody validates what they generate themselves—, they do carry computed fields (availableBikes, isFull), they can aggregate data from several sources and they usually exist in two sizes: a summary for listings and a detail for the individual lookup.

A common mistake is using the same DTO for input and output. It looks like it saves code and it produces two problems: the class ends up with fields that only make sense in one direction (an id that is null when creating and mandatory when reading), and validation constraints get applied to objects that do not need them. The extra class is worth it.

  1. Why immutable records

Every CicloUrbana DTO is a record. The reasons, compared with the classic alternative of a class with getters and setters:

Aspect record Class with setters
Lines of code 1 per DTO 5 per field
Mutability Immutable Mutable: anyone can change it
equals/hashCode/toString Generated and correct By hand or with Lombok
Thread safety Guaranteed You have to reason about it
Bean Validation On the component On the field
Normalisation Compact constructor Scattered across the setters

Immutability is what contributes most. A mutable DTO can be modified between the moment it is validated and the moment it is used, and that has caused real vulnerabilities. With a record, what was validated is exactly what reaches the service.

The compact constructor is besides the natural place to normalise, and it always runs, wherever the object comes from:

public record CreateStationRequest(
        @NotBlank @Size(min = 3, max = 80) String name,
        @NotBlank @Size(max = 120) String address,
        @Positive @Max(60) int capacity,
        @DecimalMin("-90.0")  @DecimalMax("90.0")  double latitude,
        @DecimalMin("-180.0") @DecimalMax("180.0") double longitude) {

    public CreateStationRequest {
        // Normalise before validating: "  Main Square  " -> "Main Square"
        name = name == null ? null : name.trim();
        address = address == null ? null : address.trim();
    }
}

That trim stops the name "Main Square " from being considered different from "Main Square" in the duplicate check. It is a piece of cleaning that in a class with setters would have to be repeated in every one of them.

DTOs live next to their aggregate, in a dto subpackage: com.ciclourbana.stations contains Station, StationService, StationController and StationMapper, and com.ciclourbana.stations.dto contains CreateStationRequest, UpdateStationRequest, StationResponse and StationDetailResponse.

  1. CicloUrbana's DTO hierarchy

DTO Direction Endpoints Fields
CreateStationRequest Input POST /stations name, address, capacity, latitude, longitude
UpdateStationRequest Input PUT /stations/{id} name, address, capacity
StationPatchRequest Input PATCH /stations/{id} the above, optional
StationResponse Output GET /stations id, name, address, capacity, availableBikes, location
StationDetailResponse Output GET /stations/{id} the above + freeDocks, isFull, bikes
BikeSummary Output nested in the detail id, plate, battery, status
CreateBikeRequest Input POST /bikes plate, batteryLevel, stationId
BikeResponse Output GET /bikes, /bikes/{id} id, plate, battery, status, stationId, stationName
StartRentalRequest Input POST /rentals userId, bikeId
FinishRentalRequest Input POST /rentals/{id}/finish destinationStationId
RentalResponse Output GET /rentals/{id} and both POSTs id, plate, origin, destination, startedAt, endedAt, durationMinutes, totalAmount, status

Two observations on the design. UpdateStationRequest does not include coordinates, and that is deliberate: a physical station does not move, and correcting its georeferencing would be a separate administrative operation. Request DTOs are the cleanest way of expressing what can be modified and what cannot: what is not in the DTO cannot be touched. And RentalResponse does not include bikeId but plate, because the internal identifier is useless to the mobile app, which wants to show "RB-0142" to the user; a DTO can replace identifiers with readable data and so cut down the number of client calls.

The response DTOs:

package com.ciclourbana.stations.dto;

/** Station summary for the listings. */
public record StationResponse(
        Long id, String name, String address, int capacity,
        int availableBikes,            // computed: not in Station
        LocationResponse location      // groups latitude and longitude
) {}

public record LocationResponse(double latitude, double longitude) {}

/** Detailed view: includes the docked bikes. */
public record StationDetailResponse(
        Long id, String name, String address, int capacity,
        int availableBikes,
        int freeDocks,                 // computed
        boolean isFull,                // computed
        LocationResponse location,
        List<BikeSummary> bikes
) {}

/** Minimal bike view, nested in the station detail. */
public record BikeSummary(Long id, String plate,
                          int battery, BikeStatus status) {}

BikeSummary has no reference to the station, and that is the key to why DTOs remove the circular reference problem at the root: the DTO hierarchy is a tree by construction.

  1. Manual mapping

The simplest way to map is to write the code. With records, it fits in a method:

package com.ciclourbana.stations;

@Component
public class StationMapper {

    private final BikeMapper bikeMapper;              // injected via the constructor

    /** Domain -> listing DTO. availableBikes is supplied by the service. */
    public StationResponse toResponse(Station station, int availableBikes) {
        return new StationResponse(
                station.id(), station.name(), station.address(),
                station.capacity(), availableBikes,
                new LocationResponse(station.latitude(), station.longitude()));
    }

    /** Domain + aggregates -> detail DTO, with the computed fields. */
    public StationDetailResponse toDetail(Station station, List<Bike> docked) {
        int available = (int) docked.stream()
                .filter(b -> b.status() == BikeStatus.AVAILABLE)
                .count();

        return new StationDetailResponse(
                station.id(), station.name(), station.address(),
                station.capacity(), available,
                station.capacity() - docked.size(),             // freeDocks
                docked.size() >= station.capacity(),            // isFull
                new LocationResponse(station.latitude(), station.longitude()),
                docked.stream().map(bikeMapper::toSummary).toList());
    }

    /** Request DTO -> domain. The id is null: the repository assigns it. */
    public Station toDomain(CreateStationRequest request) {
        return new Station(null, request.name(), request.address(),
                request.capacity(), request.latitude(), request.longitude());
    }

    /** Applies a partial update, preserving what does not arrive. */
    public Station apply(Station current, UpdateStationRequest request) {
        return new Station(current.id(),
                request.name() != null ? request.name() : current.name(),
                request.address() != null ? request.address() : current.address(),
                request.capacity() != null ? request.capacity() : current.capacity(),
                current.latitude(), current.longitude());   // the coordinates are untouched
    }
}
Advantages of manual mapping Drawbacks
Zero dependencies and zero magic Repetitive with DTOs that have many fields
Debugged with a breakpoint Easy to forget a new field, with no warning
Allows arbitrary logic (isFull) Grows linearly with the number of DTOs

The serious drawback is the second one: if StationResponse gains a zone field, the compiler does complain because the record's constructor changes arity, but if the new field has the same type as another, a mistake in the order goes unnoticed. That is exactly what MapStruct eliminates.

An alternative without a bean is static factory methods in the DTO itself: StationResponse.from(station, available). It is more compact, but it couples the DTO to the domain and prevents injecting dependencies into the mapping. CicloUrbana uses the mapper as a @Component.

  1. MapStruct

MapStruct is an annotation processor that generates the mapping code at compile time. It uses no reflection, so it is as fast as manual code, and since the result is compiled Java, any inconsistency is a compilation error.

The pom.xml configuration goes in the maven-compiler-plugin, alongside Lombok if you were using it:

<dependency>
    <groupId>org.mapstruct</groupId>
    <artifactId>mapstruct</artifactId>
    <version>1.6.3</version>
</dependency>

<!-- ... and in the maven-compiler-plugin: -->
<configuration>
    <annotationProcessorPaths>
        <path>
            <groupId>org.mapstruct</groupId>
            <artifactId>mapstruct-processor</artifactId>
            <version>1.6.3</version>
        </path>
    </annotationProcessorPaths>
    <compilerArgs>
        <!-- Fails the build if a target field is left unmapped -->
        <arg>-Amapstruct.unmappedTargetPolicy=ERROR</arg>
        <arg>-Amapstruct.defaultComponentModel=spring</arg>
    </compilerArgs>
</configuration>

unmappedTargetPolicy=ERROR is the option that makes MapStruct valuable: if you add a field to the response DTO and do not tell it where the value comes from, the project does not compile. It is manual mapping's silent oversight turned into a compilation error.

The mapper is an interface:

package com.ciclourbana.stations;

@Mapper(componentModel = "spring",              // generates an injectable @Component
        uses = BikeMapper.class,                // delegates the bike mapping
        unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface StationMapper {

    /** Same-named fields map themselves; the rest are declared. */
    @Mapping(target = "location.latitude",  source = "station.latitude")
    @Mapping(target = "location.longitude", source = "station.longitude")
    StationResponse toResponse(Station station, int availableBikes);

    @Mapping(target = "id", ignore = true)      // assigned by the repository
    Station toDomain(CreateStationRequest request);

    List<StationResponse> toResponses(List<Station> stations);      // collections, for free

    /** Calculations MapStruct cannot infer are written as default methods. */
    default StationDetailResponse toDetail(Station station, List<Bike> docked) {
        int available = (int) docked.stream()
                .filter(b -> b.status() == BikeStatus.AVAILABLE).count();
        return new StationDetailResponse(station.id(), station.name(),
                station.address(), station.capacity(), available,
                station.capacity() - docked.size(),
                docked.size() >= station.capacity(),
                new LocationResponse(station.latitude(), station.longitude()),
                toSummaries(docked));
    }

    List<BikeSummary> toSummaries(List<Bike> bikes);
}

On compiling with ./mvnw compile, MapStruct writes a StationMapperImpl class into target/generated-sources/annotations:

@Component
public class StationMapperImpl implements StationMapper {

    @Override
    public StationResponse toResponse(Station station, int availableBikes) {
        if (station == null) {
            return null;
        }
        LocationResponse location = new LocationResponse(
                station.latitude(), station.longitude());
        return new StationResponse(station.id(), station.name(),
                station.address(), station.capacity(),
                availableBikes, location);
    }

    @Override
    public List<StationResponse> toResponses(List<Station> stations) {
        if (stations == null) {
            return null;
        }
        List<StationResponse> list = new ArrayList<>(stations.size());
        for (Station station : stations) {
            list.add(toResponse(station, 0));
        }
        return list;
    }
}

Reading the generated code is the best habit you can pick up with MapStruct. It stops being magic: it is exactly the code you would have written by hand, null checks included. And when something does not map as you expected, the answer is right there, in a readable Java file.

For partial updates, MapStruct offers @MappingTarget, which modifies an existing object instead of creating a new one. With immutable records it does not apply directly, so in CicloUrbana the PATCH stays with the default method we already wrote. With mutable classes it would be:

@BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
void update(UpdateStationRequest request, @MappingTarget MutableStation target);

NullValuePropertyMappingStrategy.IGNORE means "if the source is null, do not touch the target": exactly the PATCH semantics that gave us so much trouble in 03-03.

  1. ModelMapper and why the course rules it out

ModelMapper does the mapping at runtime through reflection, deducing the correspondences by name. Its appeal is that it requires writing nothing:

ModelMapper mapper = new ModelMapper();
StationResponse response = mapper.map(station, StationResponse.class);

The risks, which are the reason for ruling it out:

Risk Consequence
Correspondences by reflection They fail at runtime, not at compile time
"Smart" name matching It may pair up fields you did not want
Performance Orders of magnitude slower than generated code
Debugging The mapping happens inside the library, not in your code
Refactoring The IDE does not see the correspondences: renaming breaks silently

The second risk is the worst: with the loose matching mode, ModelMapper can pair station.name with response.operatorName because they share a prefix, and publish a value in the wrong field with no warning at all.

Criterion Manual MapStruct ModelMapper
Errors caught at Compile time Compile time Runtime
Performance Maximum Maximum Low
Code to write A lot Little None
Debuggable Yes Yes (generated code) Hard
Forgotten field Silent Compilation error Silent
Learning curve None Medium Low

The course's recommendation: manual mapping when there are few DTOs or the conversion logic is substantial —that is CicloUrbana's case in this module, and it is the code that appears in the lessons—; MapStruct as soon as the project grows, for the compile-time guarantee; and ModelMapper, never in production.

  1. Where the mapping lives

Three defensible positions:

Option Argument in favour Argument against
In the controller The service does not know the HTTP contract and is reusable The controller fills up with conversion code
In the service The controller stays minimal The service gets tied to the API contract
In a dedicated mapper Single responsibility, testable on its own One more class

CicloUrbana's policy: a dedicated mapper, invoked from the controller. The service speaks the language of the domain —StationService.create(...) receives and returns Station, not DTOs—, so that when a message consumer arrives in module 7 it will be able to call it without building API objects. The controller translates, which has been its role since 03-02. And the mapper concentrates the conversion, is exercised with fast unit tests (module 6) and does not require starting the Spring context. The controller ends up like this:

@RestController
@RequestMapping(path = "/api/v1/stations", produces = MediaType.APPLICATION_JSON_VALUE)
@Validated
public class StationController {

    // Injected via the constructor: StationService, BikeService, StationMapper

    @GetMapping
    public List<StationResponse> list(
            @RequestParam(required = false) @Size(max = 80) String name,
            @RequestParam(defaultValue = "0")  @Min(0) int page,
            @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) {

        return stationService.search(name, null, page, size).stream()
                .map(s -> stationMapper.toResponse(s,
                        bikeService.countAvailable(s.id())))
                .toList();
    }

    @GetMapping("/{id:\\d+}")
    public ResponseEntity<StationDetailResponse> getById(
            @PathVariable("id") @Positive Long id) {

        return stationService.findById(id)
                .map(s -> stationMapper.toDetail(s,
                        bikeService.findByStationUnchecked(id)))
                .map(ResponseEntity::ok)
                .orElseGet(() -> ResponseEntity.notFound().build());
    }

    @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<StationResponse> create(
            @Valid @RequestBody CreateStationRequest request) {

        Station created = stationService.create(stationMapper.toDomain(request));
        URI location = ServletUriComponentsBuilder.fromCurrentRequest()
                .path("/{id}").buildAndExpand(created.id()).toUri();
        return ResponseEntity.created(location)
                .body(stationMapper.toResponse(created, 0));
    }
}

Note an important detail: StationService.create now receives a Station, not a DTO. The service's signature has stopped mentioning the API contract, which is precisely the goal.

And the response the client sees:

{
  "id": 1,
  "name": "Main Square",
  "address": "Main Square 1",
  "capacity": 24,
  "availableBikes": 7,
  "location": { "latitude": 41.3851, "longitude": 2.1734 }
}

Compare it with the one from 03-02, which was the literal dump of the record Station. This one is a design decision: it groups the coordinates, exposes a computed value and reveals not one field we do not want published.

  1. Projections and partial responses

Sometimes the client only wants a few fields. A map of Ribalta with 4 stations does not need the addresses or the capacity: the name and the coordinates are enough. Downloading the complete object is wasted traffic, and in a listing of hundreds of elements it matters.

The three strategies:

Strategy How it works Advantages Drawbacks
Specific DTOs StationMapResponse with 3 fields Typed, documented in OpenAPI One class per view
Field selection ?fields=id,name filtered dynamically Flexible Untyped, undocumented, hard to cache
Spring Data projections Interfaces the query fills in The database only reads what was asked for Module 4

CicloUrbana chooses specific DTOs for their clarity:

/** Minimal view for the city's interactive map. */
public record StationMapResponse(Long id, String name,
                                 double latitude, double longitude,
                                 int availableBikes) {}

The endpoint GET /api/v1/stations/map returns List<StationMapResponse> and drags along neither addresses nor capacities. The rule for not ending up with fifteen DTOs per resource: create a new view only when a real client asks for it and the size difference is significant. Two or three views per resource —summary, detail and perhaps a specialised one— cover almost every case.

Dynamic field selection (?fields=id,name) is tempting and almost always a mistake in a REST API: it breaks typing, makes it impossible to document the response in OpenAPI, complicates caching —every combination is a different response— and ends up being a badly done homemade GraphQL. If the project genuinely needs that flexibility, the answer is GraphQL, as we saw in 03-01.

  1. Nested DTOs and aggregates

StationDetailResponse is an aggregate: a response that combines data from several sources in a single call.

curl -s http://localhost:8080/api/v1/stations/1 | jq
{
  "id": 1,
  "name": "Main Square",
  "address": "Main Square 1",
  "capacity": 24,
  "availableBikes": 7,
  "freeDocks": 15,
  "isFull": false,
  "location": { "latitude": 41.3851, "longitude": 2.1734 },
  "bikes": [
    { "id": 12, "plate": "RB-0142", "battery": 87, "status": "AVAILABLE" },
    { "id": 13, "plate": "RB-0143", "battery": 15, "status": "MAINTENANCE" }
  ]
}

The benefit is concrete: the Ribalta app gets the whole detail screen with one request instead of two. On a slow mobile network, every round trip costs hundreds of milliseconds.

The danger is concrete too: aggregating too much. If StationDetailResponse also included the last hundred rentals and the incident history, the response would weigh megabytes and be slow for everyone, including the clients that only wanted the name. The three criteria for deciding whether a value gets nested are: does the client need it almost always on that screen? (the docked bikes, yes); is its size bounded? (there are at most capacity bikes; historical rentals grow without limit); and does it change at the same rate?, because putting together a value that updates every second with one that changes every month makes the whole thing uncacheable.

Rentals fail all three criteria, so they are not nested: they are queried with GET /api/v1/stations/1/rentals?page=0, paginated and on demand.

  1. Evolving the contract without breaking clients

DTOs are what makes the versioning policy from 03-01 workable in practice. Four real scenarios:

Adding a field is compatible: it is added to StationResponse, filled in by the mapper and ignored by old clients thanks to fail-on-unknown-properties: false. Renaming a domain field stops affecting the contract: if Station.capacity becomes totalSpaces, one line of the mapper changes and the JSON keeps saying "capacity". That, in one sentence, is the return on this lesson's whole investment.

Removing a field from the contract requires a transition: mark it deprecated in the documentation (@Schema(deprecated = true), lesson 03-07), measure how many clients use it with Actuator (module 7) and remove it in /api/v2 when usage reaches zero.

Changing the shape of a field —the loose coordinates becoming a location object— is solved with a temporary duplication:

public record StationResponse(
        Long id, String name, String address, int capacity,
        int availableBikes,
        LocationResponse location,

        /** @deprecated since 1.4.0, to be removed in /api/v2. Use location.latitude. */
        @Deprecated(since = "1.4.0", forRemoval = true) Double latitude,

        /** @deprecated since 1.4.0, to be removed in /api/v2. Use location.longitude. */
        @Deprecated(since = "1.4.0", forRemoval = true) Double longitude
) {}

The three fields coexist, the change is compatible and the mapper fills in both shapes. When the metrics say nobody reads the loose latitude any more, it is removed. Without DTOs this would be impossible without duplicating the domain entity.

Common Mistakes and Tips

Exposing the entity "just on this endpoint". The exception becomes the norm. In module 4, that endpoint will be the one that triggers the LazyInitializationException.

Using the same DTO for request and response. It ends up with null fields in one direction and useless validation in the other. Along the same lines, putting validation on response DTOs only adds noise: you do not validate what you generate yourself.

Mapping in the service. It ties the business logic to the HTTP contract. When a queue consumer arrives it will have to build API DTOs in order to call the service.

Accepting the id in the body of a POST. The identifier is assigned by the server. If the DTO includes it, a client can try to set it.

Forgetting a @Mapping in MapStruct without unmappedTargetPolicy=ERROR. The field is silently left null. Switch the policy on in the pom.xml from day one.

Nesting unbounded collections. A StationDetailResponse with every historical rental grows without limit. Apply the three criteria from section 11.

Tip: name DTOs by their use, not by their shape. CreateStationRequest and StationDetailResponse say where they are used. StationDTO and StationDTO2 say nothing.

Tip: test the mapper on its own. A StationMapperTest with no Spring context runs in milliseconds and instantly catches a misplaced field. It is the best cost-benefit test in the project (module 6).

Exercises

Exercise 1: Design and map RentalResponse

RentalController still returns the domain record Rental, which exposes userId, bikeId and the raw station identifiers. Design RentalResponse thinking about what the mobile app needs to show in a citizen's history, and implement RentalMapper. Justify which domain fields do not go out and which computed fields you add.

Exercise 2: Operator view with data that is not published

Ribalta's internal operator panel needs, for each bike, the plate, the battery, the status, the internalDockCode and the number of open incidents. None of the last three must appear in the public API. Design the solution and explain how you stop a future change from leaking internal data into the public endpoint.

Exercise 3: Migrate StationMapper to MapStruct

Convert the manual StationMapper from section 6 into a MapStruct interface. Solve the three hard cases: the availableBikes field that is not in the domain, the grouping of latitude and longitude into location, and the computed fields freeDocks and isFull.

Solutions

Solution 1.

package com.ciclourbana.rentals.dto;

public record RentalResponse(
        Long id,
        String bikePlate,               // not bikeId: the app shows "RB-0142"
        String originStation,           // name, not id
        String destinationStation,      // name, not id; null while still in progress
        LocalDateTime startedAt,
        LocalDateTime endedAt,          // null while in progress
        long durationMinutes,           // computed
        BigDecimal totalAmount,         // null while in progress
        RentalStatus status) {}

What does not go out and why:

Domain field Decision Reason
userId Not exposed The user already knows who they are; publishing it would allow enumerating users
bikeId Replaced by bikePlate The internal identifier is useless to the app
originStationId/destinationStationId Replaced by the name Avoids a second call just to resolve the name

Computed fields: durationMinutes saves every client from reimplementing the calculation, and doing it on the server guarantees everyone gets the same number.

@Component
public class RentalMapper {

    // Injected: BikeRepository, StationRepository and the Clock from CommonConfig

    public RentalResponse toResponse(Rental rental) {

        // If it is still in progress, the duration is counted up to NOW
        LocalDateTime until = rental.endedAt() != null
                ? rental.endedAt() : LocalDateTime.now(clock);

        return new RentalResponse(
                rental.id(),
                bikeRepository.findById(rental.bikeId())
                        .map(Bike::plate).orElse("unknown"),
                stationName(rental.originStationId()),
                stationName(rental.destinationStationId()),
                rental.startedAt(), rental.endedAt(),
                Duration.between(rental.startedAt(), until).toMinutes(),
                rental.totalAmount(), rental.status());
    }

    private String stationName(Long id) {
        return id == null ? null
                : stationRepository.findById(id).map(Station::name).orElse(null);
    }
}

Design warning: this mapper queries repositories, and that makes it something more than a mapper. In a listing of 100 rentals it would trigger 300 queries —module 4's N+1 problem. The ways out are for the service to return an aggregate with the names already resolved, or for the repository to bring them in a single query. If your mapper needs the repository, it is a sign that the service is not returning enough.

Solution 2.

// Public: only what any citizen may see
public record BikeResponse(Long id, String plate,
                           int battery, BikeStatus status,
                           Long stationId, String stationName) {}

// Internal: includes operational data. It lives in a different package.
public record OperatorBikeResponse(Long id, String plate,
                                   int battery, BikeStatus status,
                                   Long stationId, String stationName,
                                   String internalDockCode,
                                   int openIncidents,
                                   LocalDateTime lastInspection) {}

With separate paths and separate mappers:

GET /api/v1/bikes                  -> BikeResponse           (public)
GET /api/v1/internal/bikes         -> OperatorBikeResponse   (OPERATOR role, module 5)

How the future leak is prevented, which is the point of the exercise:

  1. They are different classes, not one with conditional fields. An if (isOperator) inside the mapper eventually fails the day someone inverts the condition or forgets it.
  2. OpenAPI's @Schema (03-07) documents each one separately, and a review of the public documentation shows immediately whether a field appears that should not.
  3. The contract test is the definitive safety net. In module 6 we will write a test that asserts exactly which keys GET /api/v1/bikes returns; if someone adds an internal field to the public response, the test fails before deployment.
  4. Never reuse the internal DTO "because it has everything". That is the temptation behind every leak.

A common anti-pattern worth ruling out explicitly: using @JsonView to serve two views from a single class. It works, but it leaves the dangerous field inside the object being serialised, relying on an annotation to keep it in. That is a deny list again. Two classes is more code and far safer.

Solution 3.

@Mapper(componentModel = "spring",
        uses = BikeMapper.class,
        unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface StationMapper {

    // Case 1: a value not present in the source arrives as an extra parameter.
    // MapStruct pairs it by name with the target record's component.
    @Mapping(target = "location", source = "station")
    StationResponse toResponse(Station station, int availableBikes);

    // Case 2: group two flat fields into a nested object.
    // A helper method MapStruct uses automatically because of its signature.
    default LocationResponse toLocation(Station station) {
        return station == null ? null
                : new LocationResponse(station.latitude(), station.longitude());
    }

    @Mapping(target = "id", ignore = true)
    Station toDomain(CreateStationRequest request);

    List<BikeSummary> toSummaries(List<Bike> bikes);

    // Case 3: calculations that depend on several sources.
    // They are written as default methods: MapStruct cannot infer them, and forcing it
    // with @Mapping and java() expressions produces unreadable code.
    default StationDetailResponse toDetail(Station station, List<Bike> docked) {
        int available = (int) docked.stream()
                .filter(b -> b.status() == BikeStatus.AVAILABLE).count();
        return new StationDetailResponse(station.id(), station.name(),
                station.address(), station.capacity(), available,
                station.capacity() - docked.size(),
                docked.size() >= station.capacity(),
                toLocation(station), toSummaries(docked));
    }
}

The three lessons of the exercise:

  • Case 1. When a value is not in the source object, it is passed as an additional method parameter. MapStruct pairs it with the target's component by name, so int availableBikes fills the availableBikes field. If the names did not match, @Mapping(target = "...", source = "...") would be needed.
  • Case 2. A default method whose signature goes from the source type to the target type becomes a converter that MapStruct uses automatically wherever it needs that transformation. It is the library's most useful and least known mechanism.
  • Case 3. When a mapping requires real logic, write it as a default method. MapStruct allows inline expressions with @Mapping(target = "isFull", expression = "java(...)"), but that puts Java code inside a text string: no type checking, no autocompletion and no refactoring. A default is ordinary Java.

And the final check, which is the reason for migrating: if tomorrow someone adds a zone field to StationResponse and does not say where it comes from, ./mvnw compile fails with Unmapped target property: "zone". In the manual mapper, that field would have been left null until a Ribalta user noticed.

Conclusion

CicloUrbana's domain and its public contract are finally two separate things. You know why exposing entities is a bad idea and you can justify it with four concrete failures: the silent leak of a password hash or a national id, the coupling that turns an innocent rename into a broken mobile app, the circular references that cause a StackOverflowError as soon as relationships exist, and the impossibility of exposing a computed value like availableBikes without polluting the model. You understand the difference between a request DTO —minimal, validated, with no generated identifiers— and a response DTO —with computed fields, no validation, in summary and detail variants—, and why they are immutable records whose compact constructor is the natural place to normalise. You have the complete table of the project's eleven DTOs, with two design decisions worth remembering: UpdateStationRequest omits the coordinates because a physical station does not move, and RentalResponse replaces the internal bikeId with the plate the citizen sees.

You know how to map in three ways and how to choose between them: manual when there are few DTOs, MapStruct as soon as it grows —with unmappedTargetPolicy=ERROR, which turns the silent oversight into a compilation error— and never ModelMapper. You have read the code MapStruct generates and you know it is not magic. You have the project's policy on where the mapping lives —a dedicated mapper invoked from the controller, with the service speaking only the language of the domain— and the criteria for deciding when to nest an aggregate and when to paginate it separately. And you know how to evolve the contract without breaking clients, with the temporary field duplication that makes almost any change compatible.

One last gap remains, and it is the most visible from the outside. When something goes wrong, CicloUrbana responds with {"type":"about:blank","title":"Bad Request","status":400,"detail":"Invalid request content."}, which does not say which field failed. A ConstraintViolationException from 03-04 comes out as a 500. The 404s are built by hand in every controller with ResponseEntity.notFound(). And the business rules from 03-03 throw IllegalStateExceptions that turn into server errors with the stack trace inside, when they should be clean 409s. All that debt has been piling up for three lessons with TODO labels.

Lesson 03-06, Exception Handling in REST, settles it completely. We will see what Spring Boot does by default and why it is not enough for a public API; we will build CicloUrbana's exception hierarchy with its mapping to HTTP codes; we will centralise everything in a @RestControllerAdvice; we will adopt the standard RFC 7807 Problem Details format with Spring Boot 3's native support; we will turn 03-04's validation errors into a response with the exact list of offending fields; we will handle the framework's own exceptions; and we will add a trace identifier that lets any error be correlated with the server logs.

Spring Boot Course

Module 1: Introduction to Spring Boot

Module 2: Spring Boot Core Concepts

Module 3: Building RESTful Web Services

Module 4: Data Access with Spring Boot

Module 5: Security in Spring Boot

Module 6: Testing in Spring Boot

Module 7: Advanced Spring Boot Features

Module 8: Deploying Spring Boot Applications

Module 9: Performance and Monitoring

Module 10: Best Practices and Tips

© Copyright 2026. All rights reserved