The two previous lessons answered "is it well made?" from two angles: what to do and what to avoid. The third is missing, and it produces no errors at all yet decides the project's future: how it reads.

A RentalService can pass all thirty-eight checks from 10-01, contain not one of the mistakes from 10-02 and still be a two-hundred-line method with three boolean flags, variables called data and temp, comments that repeat what the code already says and a domain that is a bag of setters. None of that fails today. What fails is six months from now, when Ribalta council asks for dynamic fares and nobody dares touch that method.

This lesson is about that, with a single criterion that runs through every section and is worth fixing before we start: the audience for the code is not the compiler, it is the next person who reads it. And that next person is frequently you, a year from now, remembering nothing.

Contents

  1. What clean code means here
  2. Names
  3. Functions and classes
  4. A complete refactoring: RentalService
  5. SOLID in Spring, with examples from Ribalta
  6. Comments
  7. Error handling
  8. Immutability
  9. Optional used well
  10. The anaemic domain model
  11. Verifiable architecture with ArchUnit
  12. Style and automation
  13. Safe refactoring
  14. Technical debt
  15. Common Mistakes and Tips
  16. Exercises

  1. What clean code means here

"Clean code" is often confused with "pretty code", and they are not the same thing. An operational criterion, one you can apply without arguing about taste:

Property Question it answers How you check it
Readable Can you tell what it does without running it? An outsider reads it and explains it
Locatable Do I know where to touch in order to change X? You look for the place a hypothetical change would go
Modifiable Does changing one thing force you to change five? You count the radius of a real change
Testable Can I write a test without acrobatics? You try to write it

The fourth is the most objective and the most informative. If testing a method requires mocking static methods, instantiating six collaborators or manipulating the system clock, the problem is not the test: it is the design. It is the reason we injected a Clock back in 02-01 and the reason RentalSecurity is an ordinary bean instead of a three-line SpEL expression. And a warning about scope: nothing in this lesson is a matter of personal style — indentation, braces and line length are settled by a tool in section 12 and are not argued about in reviews. What follows is design.

  1. Names

Naming is the most frequent activity in programming and the one with the highest return per minute invested.

2.1. The course's convention

CicloUrbana has an explicit rule worth stating because it is not universal: the domain is named in the council's own words — Station, Bike, Rental, calculateAmount, availableBikes — and technical vocabulary is reserved for what belongs to the framework or a library: annotations, Spring types, findById, Pageable. The reason is not aesthetic, it is ubiquitous language: Ribalta council talks about stations, docks and fares, and when the code uses those same words, the mental translation between the conversation and the program disappears. What must be avoided at all costs is the hybrid: StationService.getStationRowByLabel() makes you switch vocabulary twice in a single line.

Element Convention CicloUrbana example
Class Noun, PascalCase RentalService, FareSelector
Interface A noun or a capability, no I prefix FareCalculator, RentalValidation
Method Verb in the infinitive calculate, findWithAvailability, isOwner
Boolean is..., has..., can... isFull(), canBeRented()
Variable and constant A concrete noun; UPPERCASE_WITH_UNDERSCORES freeDocks, FREE_MINUTES
Package Lowercase, by feature com.ciclourbana.rentals
Test A sentence describing the rule withExactlyFifteenMinutesTheRentalIsStillFree

2.2. Names that reveal intent

The difference between a name and a good name is whether it forces you to read the implementation.

// ❌ You have to read the body to know what it returns
public List<Station> getData(int x) { ... }
public List<Station> process2(boolean b) { ... }

// ✅ The name is the documentation
public List<Station> findWithAvailability(int minimumBikes) { ... }
public List<Station> findOperationalAtPeakTime() { ... }

getData() fails on three fronts: it does not say what data, it does not say where it comes from and it does not say by what criterion it is filtered. findWithAvailability(int minimumBikes) answers all three, and the parameter name turns the number at the call site into information: findWithAvailability(3) reads as "stations with at least three bikes".

Four practical rules: no abbreviations except the universally known ones (id, url, http); no numbers in names, because process1 and process2 mean you do not know what distinguishes them; the name indicates the return type — anything starting with is or can returns a boolean, anything starting with find may not find anything and returns an Optional or a list —; and the same concept, always the same word: if it is find, it is not get in the class next door.

  1. Functions and classes

3.1. A single level of abstraction

The most useful rule about the size of a function is not "under twenty lines", but: every statement in a method must be at the same level of detail.

// ❌ Mixes "what is done" with "how it is done"
public RentalResponse start(StartRentalRequest request) {
    Bike bike = bikeRepository.lockBestAvailable(
            request.originStationId(), network.batteryThreshold())
            .orElseThrow(() -> new BikeUnavailableException(request.originStationId()));
    if (rentalRepository.countByUserIdAndEndedAtIsNull(request.userId()) >= 1) {
        throw new BusinessRuleException("RENTAL_IN_PROGRESS", "You already have an open rental");
    }
    bike.setStatus(BikeStatus.IN_USE);
    // ... twenty more lines at the same level of detail
}

// ✅ The public method tells the story; the private ones fill in the detail
public RentalResponse start(StartRentalRequest request) {
    validateNoOpenRental(request.userId());
    Bike bike = reserveBestBike(request.originStationId());
    Rental rental = recordRental(request, bike);
    events.publishEvent(new RentalStarted(rental.getId(), currentInstant()));
    return rentalMapper.toResponse(rental);
}

The public method reads like a paragraph and is understood in five seconds. Whoever needs the detail goes down a level; whoever only wants to know what happens, does not.

3.2. Arguments, and why boolean flags are a problem

Zero, one or two arguments is ideal; three is acceptable if they belong to the same concept; with four or more there is almost always an object missing to group them.

A boolean flag is an argument that makes the method do two different things, and it has three defects: nothing can be read at the call site (finish(9L, true) does not say what true is), it forces an if inside that separates two flows which are already two methods, and it grows: tomorrow there are two flags and four combinations, two of which make no sense.

// ❌ What does that true mean at the call site?
public RentalResponse finish(Long id, boolean applyPenalty) { ... }
finish(9L, true);

// ✅ Two named methods
public RentalResponse finish(Long id) { ... }
public RentalResponse finishAsExpired(Long id) { ... }   // applies the penalty

3.3. Single responsibility, genuinely applied

"One class, one responsibility" is quoted a lot and applied badly, because "responsibility" is vague. The operational formulation is better: a class must have a single reason to change, that is, a single stakeholder who might ask for it to be modified. If RentalService changes when the council revises the fares, when billing changes the invoice format and when the mail provider changes its API, it has three reasons to change and three different people asking for them. The next section fixes it.

  1. A complete refactoring: RentalService

We start from a real, plausible version of RentalService.finish: it works, it passes the tests and it does too much.

// ❌ Before: 5 responsibilities in one method
@Transactional
public RentalResponse finish(Long id, FinishRentalRequest request) {
    Rental rental = rentalRepository.findById(id).orElseThrow();
    if (rental.getEndedAt() != null) throw new ResourceConflictException("Already finished");
    Station destination = stationRepository.findById(request.destinationStationId()).orElseThrow();
    if (bikeRepository.countByStationId(destination.getId()) >= destination.getCapacity()) {
        throw new StationFullException(destination.getId());
    }

    // Amount calculation, reimplemented by hand inside the service
    long minutes = Duration.between(rental.getStartedAt(), Instant.now()).toMinutes();
    BigDecimal totalAmount;
    if (rental.getUser().getFareType() == FareType.STUDENT) {
        totalAmount = new BigDecimal("0.08").multiply(BigDecimal.valueOf(Math.max(0, minutes - 15)));
    } else if (rental.getUser().getFareType() == FareType.SENIOR) {
        totalAmount = new BigDecimal("0.05").multiply(BigDecimal.valueOf(Math.max(0, minutes - 30)));
    } else {
        totalAmount = new BigDecimal("0.50")
                .add(new BigDecimal("0.12").multiply(BigDecimal.valueOf(Math.max(1, minutes))));
    }
    totalAmount = totalAmount.setScale(2, RoundingMode.HALF_UP);
    if (minutes > 120) totalAmount = totalAmount.add(new BigDecimal("5.00"));   // overtime surcharge

    rental.setEndedAt(Instant.now());                  // mutation by setters
    rental.setDestinationStation(destination);
    rental.setTotalAmount(totalAmount);
    rental.setStatus(RentalStatus.FINISHED);
    rental.getBike().setStatus(BikeStatus.AVAILABLE);
    rental.getBike().setStation(destination);

    mail.sendSummary(rental.getUser().getEmail(), totalAmount);   // inside the transaction
    metrics.recordFinish(rental);
    return new RentalResponse(rental.getId(), /* ... 8 more fields ... */);
}

The five responsibilities are five different reasons to change this class, and each has a different stakeholder: validating the finishing rules (the council), calculating the amount (the fares department), applying the overtime surcharge (the council, in another meeting), mutating the state of the rental and the bike (the domain team) and notifying and measuring (marketing and operations).

Step 1: extract the fare calculation to where it already existed

The if/else if/else reimplements what FareSelector and the three FareCalculator implementations from 02-02 already did. It is pure duplication, and worse: a second source of truth that can diverge from the first. The fifteen lines become fareSelector.forUser(rental.getUser()).calculate(duration).

Step 2: the surcharge is a fare, not an if

The if (minutes > 120) is a business rule hidden inside a service. Since the fare system is already extensible by design, the surcharge fits as a decorator:

package com.ciclourbana.rentals;

/** Overtime surcharge on top of any base fare. A decorator: it knows only
 *  the contract, not the concrete fare. */
public record FareWithOvertimeSurcharge(FareCalculator base, Duration maximumDuration,
                                        BigDecimal surcharge) implements FareCalculator {
    @Override
    public BigDecimal calculate(Duration duration) {
        BigDecimal amount = base.calculate(duration);
        return duration.compareTo(maximumDuration) > 0 ? amount.add(surcharge) : amount;
    }

    @Override
    public String name() { return base.name() + "-with-surcharge"; }
}

Now the surcharge is tested on its own, combines with any present or future fare and is configured through properties. RentalService stops knowing it exists.

Step 3: the behaviour goes to the domain

The six consecutive setters are the symptom of the anaemic model in section 10. The "finish a rental" operation belongs to Rental, which is what knows its invariants:

// In the Rental entity:
/** Closes the rental. Invariant: a finished rental is never finished again. */
public void finish(Station destination, Instant endedAt, BigDecimal totalAmount) {
    if (this.status == RentalStatus.FINISHED) {
        throw new ResourceConflictException("Rental " + id + " has already finished");
    }
    this.endedAt = endedAt;
    this.destinationStation = destination;
    this.totalAmount = totalAmount;
    this.status = RentalStatus.FINISHED;
    this.bike.dockAt(destination);             // the bike manages its own state
}

Note what you gain: the "already finished" check stops depending on the service remembering it. Any path that calls finish applies it.

Step 4: the side effects, outside the transaction

The email and the metric are moved out to an AFTER_COMMIT event, as we already established in Transactions and Scheduled Tasks and Asynchrony.

The result

// ✅ After: one responsibility and zero hidden business rules
@Transactional
public RentalResponse finish(Long id, FinishRentalRequest request) {
    Rental rental = findInProgress(id);
    Station destination = findStationWithSpace(request.destinationStationId());

    Instant endedAt = Instant.now(clock);
    BigDecimal totalAmount = fareSelector.forUser(rental.getUser())
            .calculate(Duration.between(rental.getStartedAt(), endedAt));

    rental.finish(destination, endedAt, totalAmount);   // the domain protects its rules

    events.publishEvent(new RentalFinished(rental.getId(), totalAmount, endedAt));
    return rentalMapper.toResponse(rental);
}
Before After
Lines in the method / reasons to change 38 / 5 11 / 1
Business rules in the service 3 0
Testing the surcharge With a context and data A 1 ms unit test
Adding a new fare Touch this method A new class

And the condition without which none of this gets done: the module 6 tests were green before we started and stayed green after every step. Without them, this refactoring is a blind rewrite.

  1. SOLID in Spring, with examples from Ribalta

Principle What it says Where you see it in CicloUrbana
SRP — single responsibility A single reason to change The refactoring in section 4; RentalSecurity separated from RentalService
OCP — open/closed Open to extension, closed to modification FareCalculator: adding SeniorFare does not touch FareSelector, which injects them all into a List
LSP — Liskov substitution A subtype must be able to replace the base type with no surprises Any FareCalculator must return a non-negative amount; one that threw an exception on zero duration would break all its consumers
ISP — interface segregation Several small interfaces beat one large one The repositories: StationRepository exposes station things and nothing else; and Pageable/Sort instead of a method with eight parameters
DIP — dependency inversion Depend on abstractions, not implementations It is literally what the container does: RentalService declares a FareCalculator and Spring decides which one to inject

Two misunderstandings worth clearing up. DIP does not mean "one interface per class": it means the dependency points at the abstraction when there is a real boundary. RentalService depends on FareCalculator because there are three implementations and there will be more; an IRentalService with a single implementation is ceremony with no benefit. And LSP is the most ignored and the one that produces the strangest failures: it is typically violated not through inheritance but through the contract — an implementation that returns null where the others return an empty list, or that throws where the others return zero — and the consumer, written against the first one's behaviour, breaks with the second.

  1. Comments

A comment is a maintenance debt: the compiler does not check it, no test runs it and it ages silently. That is why the criterion is demanding.

Comment Worth it?
Repeats what the code says No. // increment i above i++
Explains what a long method does No. Extract a named method
Explains why a non-obvious decision was taken Yes. The most valuable kind
Documents an external constraint Yes. "The provider limits us to 100 requests/min"
Warns about a trap Yes. "Do not make this private: you lose the proxy"
Commented-out code, or a // TODO with no date or owner No. That is what version control and the issue tracker are for
// ❌ Noise: it says what you can already read
// Check whether the station is full
if (bikes.size() >= station.getCapacity()) { ... }

// ✅ Explains what the code cannot say
// The council requires that one dock is always left free for the maintenance
// van (2026 agreement), hence the -1 and not the full capacity.
if (bikes.size() >= station.getCapacity() - 1) { ... }

The javadoc worth writing is the one on boundaries: public interfaces, domain contracts and any method whose behaviour is not obvious at the edge cases — FareCalculator.calculate documents what happens with a zero duration, because that is not in the signature. By contrast, a /** Returns the name. @return the name */ above getName() only adds lines.

  1. Error handling

Domain-specific exceptions, not generic ones. throw new RuntimeException("Error") forces whoever catches it to read the message to find out what happened. The hierarchy from 03-06 — CicloUrbanaException with ResourceNotFoundException, ResourceConflictException, StationFullException and BikeUnavailableException — lets the @RestControllerAdvice translate each one into its HTTP code without a single if on strings.

Do not use exceptions for the normal flow. "There are no bikes available" at a busy station is not exceptional: it happens a hundred times a day. An exception costs you building the stack trace and, above all, communicates the wrong thing to the reader; if the case is expected, return an Optional or a result type. And do not catch Exception: it also traps what you do not know how to handle and turns it into a log line. Catch what you can deal with, and let the rest rise.

// ❌ Swallows everything, including failures that should reach the global handler
try { ... } catch (Exception e) { log.error("Error", e); }

// ✅ Handle what you expected, rethrow as a domain exception
try {
    paymentGateway.charge(totalAmount);
} catch (PaymentDeclinedException e) {
    throw new BusinessRuleException("PAYMENT_DECLINED", "The payment has been declined", e);
}

Useful messages. "Error" is worth nothing; "Station 3 is full: 18 bikes for 18 docks" says what happened, with which data, and lets you reproduce it. With one condition you must not forget: the message that goes to the client and the one that goes to the log are not the same. Detail to the log; to the client, the version with no internal information.

  1. Immutability

An immutable object cannot be in an invalid state after construction, is thread-safe without any reasoning and cannot change between the moment it is validated and the moment it is used — a real source of vulnerabilities.

record for DTOs and value objects. Every CicloUrbana DTO is one, with the compact constructor as the natural place to normalise; and so are the domain's value objects, such as LocationResponse. Immutable collections in return values, because returning the internal list lets the caller modify it behind your back:

// ❌ The caller can do station.getBikes().clear()
public List<Bike> getBikes() { return bikes; }

// ✅ A read-only view; modifications go through named methods
public List<Bike> getBikes() { return List.copyOf(bikes); }
public void dock(Bike bike) { /* validates capacity and adds */ }

And the honest limit: JPA entities cannot be immutable. Hibernate needs a no-argument constructor and modifies state when loading and when applying changes. The answer is not to force immutability but to control the mutation: private fields, no indiscriminate public setters, and methods with business names — finish, dockAt, markAsBroken — that are the only things that change the state and that can protect the invariants.

  1. Optional used well

Optional was introduced for one specific case — the return value of a method that may not find anything — and is frequently used for three more, where it gets in the way.

Use Correct? Why
Return type Yes It is its purpose: it forces the caller to decide what to do when there is no value
Method parameter No The caller has to wrap; and there are three possible states: null, empty and present
Field of a class No It is not serialisable, it takes extra memory and it complicates Jackson and JPA binding
Empty collection No An empty list already expresses "there is nothing"; Optional<List<T>> is redundant

And the most frequent antipattern of all: .get() without checking, which turns an expected case into a NoSuchElementException with a 500 and a stack trace in the log.

// ❌ A 500 instead of a 404
Station station = stationRepository.findById(id).get();

// ✅ Absence is translated into a domain exception
Station station = stationRepository.findById(id)
        .orElseThrow(() -> new ResourceNotFoundException("Station", id));

orElseThrow, orElseGet, map, filter and ifPresent cover practically everything. One detail that gets overlooked: orElse always evaluates its argument, even when there is a value, so if building the alternative is expensive it must be orElseGet with a lazy supplier.

  1. The anaemic domain model

An anaemic model is one in which the entities hold only data — fields, getters and setters — and all the logic lives in the services. It is the default style of most Spring projects, and it deserves an honest discussion because it is not always wrong.

The case for putting behaviour in the domain. Compare the two ways of finishing a rental:

// ❌ The service manipulates the state with setters: the invariants live in the service
rental.setEndedAt(endedAt);
rental.setDestinationStation(destination);
rental.setTotalAmount(totalAmount);
rental.setStatus(RentalStatus.FINISHED);

// ✅ The entity protects its own rules
rental.finish(destination, endedAt, totalAmount);

Four differences that matter. The invariant travels with the data: the "already finished" check is applied on every path, not only the one that remembered to write it. The entity cannot be left half done: with public setters, a method can set the amount and forget the status, and nothing prevents it. The name is in the council's language, not the database's. And it can be tested with nothing at all: Rental.finish is a one-millisecond unit test.

The case for the anaemic model exists and should not be dismissed:

Situation Why the anaemic model is acceptable
CRUD with no rules If "update a station's address" is assigning a field, wrapping it adds nothing
The logic needs collaborators Calculating the amount requires FareSelector; a JPA entity should not inject services
The rule crosses several aggregates "A user cannot have two open rentals" does not fit inside Rental
A team with no DDD experience A badly done rich domain — entities with repositories inside — is worse than an anaemic one

CicloUrbana's practical criterion, which is a defensible middle ground: rules that depend only on the state of the aggregate itself go in the entity — finish, canBeRented, isFull, dockAt —; those that need collaborators or cross aggregates go in the service — selecting the fare, verifying there is no other open rental, checking permissions. With that division, Rental knows no repository and RentalService does not manipulate state with setters.

  1. Verifiable architecture with ArchUnit

The dependency rules from 10-01 — the controller does not touch the repository, the domain does not import the framework — are agreements that get broken sooner or later if they depend on somebody noticing during a review. ArchUnit turns them into tests.

With the dependency com.tngtech.archunit:archunit-junit5:1.3.0 in test scope, the rules are written as static fields annotated with @ArchTest:

package com.ciclourbana;

import com.tngtech.archunit.junit.*;
import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*;
import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices;

@AnalyzeClasses(packages = "com.ciclourbana",
                importOptions = ImportOption.DoNotIncludeTests.class)
class ArchitectureRulesTest {

    /** Rule 1: controllers talk to services, never to repositories. */
    @ArchTest
    static final ArchRule controllersDoNotAccessRepositories =
            noClasses().that().haveSimpleNameEndingWith("Controller")
                    .should().dependOnClassesThat().haveSimpleNameEndingWith("Repository")
                    .because("the controller translates HTTP; the rules live in the service");

    /** Rule 2: entities do not leave their aggregate's package. */
    @ArchTest
    static final ArchRule entitiesDoNotLeaveTheirPackage =
            classes().that().areAnnotatedWith(jakarta.persistence.Entity.class)
                    .should().onlyBeAccessed().byClassesThat()
                    .resideInAnyPackage("com.ciclourbana.(**)", "com.ciclourbana.common..")
                    .because("the public contract is expressed with DTOs, not with entities");

    /** Rule 3: nothing in the domain knows the web layer. */
    @ArchTest
    static final ArchRule theDomainDoesNotKnowTheServlet =
            noClasses().that().resideInAPackage("..rentals..")
                    .and().haveSimpleNameNotEndingWith("Controller")
                    .should().dependOnClassesThat().resideInAPackage("jakarta.servlet..")
                    .because("the rental logic does not depend on the input being HTTP");

    /** Rule 4: no cycles between the business modules. */
    @ArchTest
    static final ArchRule noCyclesBetweenModules =
            slices().matching("com.ciclourbana.(*)..").should().beFreeOfCycles();

    /** Rule 5: the proxy trap from 10-02, turned into a test. */
    @ArchTest
    static final ArchRule proxyAnnotationsGoOnPublicMethods =
            methods().that().areAnnotatedWith(Transactional.class).should().bePublic()
                    .because("CGLIB does not intercept non-public methods");
}

Five rules, one file, and from that moment on ./mvnw test turns red the day somebody breaks them, with a message that says which class, which dependency and — thanks to the because — why it is forbidden. It is the difference between a documented convention and a convention that is actually followed. Three tips so the investment does not backfire: start with three or four rules, the ones that genuinely hurt when broken; always write the because, because the failure message is the only thing whoever breaks it a year from now will see; and if a rule needs exceptions, declare them explicitly instead of deleting the whole rule.

  1. Style and automation

Style — where the braces go, how many spaces, the order of the imports, the line length — is the discussion with the worst ratio of time invested to value obtained in the whole of software engineering. The solution is not to agree on a style: it is to delegate it to a tool.

<plugin>
    <groupId>com.diffplug.spotless</groupId>
    <artifactId>spotless-maven-plugin</artifactId>
    <version>2.44.0</version>
    <configuration>
        <java>
            <palantirJavaFormat/>            <!-- deterministic formatting -->
            <removeUnusedImports/>
            <importOrder><order>java,javax,jakarta,org,com,</order></importOrder>
            <trimTrailingWhitespace/>
            <endWithNewline/>
        </java>
    </configuration>
    <!-- Hooked to the validate phase: fails if anything is not formatted -->
    <executions><execution><phase>validate</phase>
        <goals><goal>check</goal></goals></execution></executions>
</plugin>

./mvnw spotless:apply formats the whole project and ./mvnw spotless:check fails if anything is not, which is what the pipeline from 08-05 runs on every change.

Tool What it checks When it runs
Spotless Formatting: whitespace, imports, line breaks In validate, and with apply locally
Checkstyle Conventions: names, method size, javadoc In CI
SpotBugs / Error Prone Likely bugs: inconsistent equals, suspicious comparisons In CI
SonarQube Global analysis, debt, duplication, coverage Nightly or per pull request
ArchUnit Architecture rules (section 11) With the tests

Why style is not argued about in reviews. A comment saying "two spaces missing" consumes the attention that should go to the logic, generates friction and adds nothing a tool does not do for free. With Spotless in validate, the code arrives already formatted and the review deals with what no tool detects: whether the design is correct, whether the name reveals the intent and whether an edge case is missing. And the order matters when adopting it: first apply the formatter to the whole project in a dedicated commit, with no functional change, and only then switch the check on; mixing reformatting and logic produces unreviewable diffs.

  1. Safe refactoring

Refactoring is changing the internal structure without changing the observable behaviour, and the definition contains its own requirement: to know the behaviour has not changed, you have to be able to check it.

flowchart LR
    A["Tests green<br/>BEFORE touching anything"] --> B["One small,<br/>reversible change"]
    B --> C["Tests green"]
    C -->|"Yes"| D["Commit"]
    C -->|"No"| E["Undo and<br/>make it smaller"]
    D --> B

The four rules, in order of importance:

  1. If there are no tests, the first task is to write them. Not for the whole system: for the behaviour you are about to move. They are called characterisation tests and they assert what the code does today, quirks included. Without them you are not refactoring, you are rewriting.
  2. One step at a time, with the tests in between. The refactoring in section 4 was four steps, each with the suite green. If step 3 broke something, you would know exactly which one it was.
  3. Do not mix refactoring and functionality in the same commit. A change that moves two hundred lines and also fixes a bug is unreviewable: nobody can separate the movement from the change.
  4. Trust the IDE for the mechanical parts. Rename, extract method or class, change signature and introduce parameter are transformations the IDE performs without making mistakes; doing them with find-and-replace is where the errors appear.

When to refactor. The sustainable answer is not "we'll set aside a sprint": it is the campsite rule, leaving the code a little better than you found it every time you touch it for another reason. A refactoring sprint competes with features and always loses; a ten-minute improvement inside a task you were doing anyway competes with nothing.

  1. Technical debt

The metaphor is exact, and that is why it works: you borrow time today and pay interest every time you touch that code. As with real finance, there is reasonable debt and ruinous debt.

Type Example in CicloUrbana Acceptable?
Deliberate and prudent "We ship with manual mapping; we will migrate to MapStruct when we reach 20 DTOs" Yes, if it is recorded
Deliberate and reckless "There is no time for tests" No: the interest is immediate
Accidental and prudent "We now understand that the mapper should not query repositories" Unavoidable and healthy: it is learning
Accidental and reckless Nobody knew that @Transactional does not work on self-invocation Training, review and the rules in section 11

How to recognise it. Four signals: an estimate that grows without the requirement growing; a file that turns up in 80% of the commits; a part of the code that "only so-and-so touches"; and the phrase "don't touch it, it works", an explicit admission that nobody understands it.

How to record it. A // TODO with no date or owner is decoration: six months later there are a hundred and forty and nobody reads them. What does work:

// DEBT-2026-03: the mapper queries repositories and causes N+1 in listings.
// Impact: p95 of GET /api/v1/rentals. Way out: have the service return
// an aggregate with the names already resolved. Estimate: 1 day. Ticket: CU-412.

The difference from a TODO is that it has an owner, a cost and an impact, which is the information needed to prioritise it against a feature; and it comes with a ticket in the same system where the rest of the work lives, because debt that exists only in the code never gets planned.

When to pay it. Three moments, and none of them is "when there is time": when you are going to touch that area for another reason — the interest pays for itself —; when it blocks something the council actually wants; and when the cost of the interest exceeds the cost of paying it off, which is detected by measuring, not by arguing. Debt that bothers nobody can stay: not everything improvable deserves to be improved.

Common Mistakes and Tips

Confusing clean code with "elegant" code. A chain of five nested stream()s can be very clever and unreadable. If you have to read it three times, a loop with clear names is better code.

Refactoring without tests. That is rewriting under another name. If there are no tests, write them first: the characterisation ones first, then the change. And applying SOLID as a ritual — one interface per class, one mapper per DTO, one factory per service — is not design: it is ceremony. Every abstraction has to be paying for something concrete.

Commenting what could be named. If you need a comment to explain what a block does, what you almost always need is to extract that block into a method with that name. And leaving commented-out code "just in case" only raises doubts about whether it should be active: it is in version control.

Confusing the rich model with putting repositories inside entities. An entity that injects a repository is worse than the anaemic model: it mixes persistence and domain and makes it impossible to test in isolation.

Tip: the best readability test is reading it out loud. If rental.finish(destination, endedAt, totalAmount) reads like a sentence from the council and process(a, true, 2) does not, you already have the answer without arguing about style.

Tip: write the code thinking about whoever will delete it. Code that is easy to delete — clear boundaries, few incoming dependencies — is the same code that is easy to change: if removing SeniorFare means deleting one class, the design is good.

Tip: code review deals with what no tool can see. Spotless looks at the formatting, ArchUnit at the dependencies, SpotBugs at likely bugs and JaCoCo at coverage. What is left for people is whether the name reveals the intent, whether the abstraction is the right one and whether an edge case is missing. That is exactly where a review adds value.

Exercises

Exercise 1: cleaning up an incident service

Refactor this class applying what we have covered: names, level of abstraction, boolean flags, Optional, error handling and the domain model. Justify each change.

@Service
public class IncService {

    @Transactional
    public Object proc(Long id, boolean close, boolean notify) throws Exception {
        Incident i = repo.findById(id).get();
        if (close) {
            i.setStatus("CLOSED");
            i.setClosedAt(new Date());
            if (i.getType().equals("BATTERY")) {
                i.getBike().setStatus("AVAILABLE");
                i.getBike().setBatteryLevel(100);
            }
        } else {
            i.setStatus("OPEN");
        }
        repo.save(i);
        if (notify) {
            try { mail.send(i.getReporter().getEmail(), "Incident " + id); }
            catch (Exception e) { }
        }
        return i;
    }
}

Exercise 2: ArchUnit rules for Ribalta

Write four ArchUnit rules that protect decisions taken throughout the course, different from the five in section 11. For each one, state which decision it protects, in which lesson it was taken and what message it would give on failure.

Exercise 3: anaemic or rich?

For each of these six CicloUrbana business rules, decide whether it should live in the entity or in the service, and justify it with the criterion from section 10: (1) a bike with less than 20% battery cannot be rented; (2) a user cannot have two open rentals at once; (3) a station is full when its docked bikes equal its capacity; (4) the amount depends on the user's fare type; (5) a finished rental cannot be finished again; (6) only an operator can mark a bike as broken.

Solutions

Solution 1

@Service
@Transactional(readOnly = true)
public class IncidentService {   // constructor omitted: repository, events, mapper, clock

    /** Closes the incident and returns the bike to service if appropriate. */
    @Transactional
    public IncidentResponse close(Long incidentId, String resolution) {
        Incident incident = find(incidentId);
        incident.close(resolution, Instant.now(clock));      // the domain decides
        events.publishEvent(new IncidentClosed(incidentId,
                incident.getReporter().getEmail()));
        return incidentMapper.toResponse(incident);
    }

    /** Reopens an incident closed by mistake. A separate method, not a flag. */
    @Transactional
    public IncidentResponse reopen(Long incidentId, String reason) { ... }

    private Incident find(Long id) {
        return incidentRepository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Incident", id));
    }
}

// In the Incident entity:
/** Closes the incident. If it was a battery one, the bike returns to service. */
public void close(String resolution, Instant moment) {
    if (this.status == IncidentStatus.CLOSED) {
        throw new ResourceConflictException("Incident " + id + " was already closed");
    }
    this.status = IncidentStatus.CLOSED;
    this.closedAt = moment;
    this.resolution = resolution;
    if (this instanceof BatteryIncident) {
        bike.returnToService();                  // the bike manages its own state
    }
}

The ten changes, justified.

# Change Reason
1 IncService → IncidentService, proc → close/reopen An abbreviated name saves nothing and costs the reader a lookup
2 The two boolean flags become two methods proc(9L, true, false) cannot be read; and of the four combinations, two made no sense
3 Object → IncidentResponse Object throws away the typing; returning the entity would be the leak from 03-05
4 throws Exception disappears; .get() → orElseThrow The domain hierarchy extends RuntimeException and triggers a rollback; and absence gives a 404, not a 500
5 String → enum for status and type equals("BATTERY") fails silently on a typo; the enum does not compile
6 new Date() → Instant.now(clock) Determinism: the test controls time with Clock.fixed
7 repo.save(i) disappears The entity is managed; dirty checking generates the UPDATE
8 The email moves to an AFTER_COMMIT event It does not hold the connection during the network call and does not announce something that can still be undone
9 The empty catch (Exception e) { } disappears It is the worst fragment in the class: it discards the failure without a trace

And the deep change: the closing logic has moved into Incident, where the "it is not closed twice" invariant is applied on every path.

Solution 2

/** Decision (03-05, 04-07): the service returns DTOs, never entities. */
@ArchTest
static final ArchRule servicesDoNotReturnEntities =
        noMethods().that().areDeclaredInClassesThat().haveSimpleNameEndingWith("Service")
                .and().arePublic()
                .should().haveRawReturnType(describe("a JPA entity",
                        c -> c.isAnnotatedWith(jakarta.persistence.Entity.class)))
                .because("with open-in-view: false it causes LazyInitializationException");

/** Decision (03-05): DTOs are immutable records. */
@ArchTest
static final ArchRule dtosAreRecords =
        classes().that().resideInAPackage("..dto..").should().beRecords()
                .because("a mutable DTO can change between being validated and being used");

/** Decision (09-05): logging is governed by Logback, never by System.out. */
@ArchTest
static final ArchRule noDirectStandardOutput =
        noClasses().should().accessField(System.class, "out")
                .because("a println carries no traceId, no level and no JSON format");

/** Decision (02-02): constructor injection with final fields. */
@ArchTest
static final ArchRule noFieldInjection =
        noFields().should().beAnnotatedWith(Autowired.class)
                .because("it prevents final and forces reflection to build the class in a test");

Why these four and not others: all four protect decisions that get broken through carelessness and produce no immediate error. An ArchUnit rule about something that already fails at compile time adds nothing; its value lies exactly in the silent agreements. And the because is not decorative: it is the only thing whoever breaks it in two years' time will see, so it should carry the concrete reason or the lesson, not a "this is forbidden".

Solution 3

# Rule Where it lives Justification
1 Minimum battery to rent Entity Bike.canBeRented(threshold) It depends only on its own state. The threshold arrives as a parameter from NetworkProperties, so the entity does not need to know about configuration
2 One open rental per user Service It crosses aggregates and needs to query the repository: Rental cannot know how many other rentals exist
3 Station full Entity Station.isFull() A comparison between two pieces of data in the aggregate itself. It is the clearest of the six
4 Amount according to fare Service It requires the FareSelector collaborator. Putting it inside Rental would force injecting a service into an entity, which is worse than the anaemic model
5 Do not finish twice Entity Rental.finish(...) It is an invariant of the aggregate, and putting it in the entity guarantees that no path can skip it
6 Only an operator marks a bike broken Service, with @PreAuthorize It is authorisation, not domain. It depends on the authenticated user, a concept foreign to Bike

The pattern that emerges, and which is the exercise's answer: the rule lives in the entity when it can be evaluated with what the entity already has in front of it. As soon as you need to consult something else — another aggregate, a service, the authenticated user — it moves up to the service. Case 1 usually generates debate, because you could argue that the threshold is configuration and therefore the rule belongs to the service; passing it as an argument keeps the entity clean and the rule next to the data. When in doubt, the useful question is: can I test this rule with a new and nothing else? If the answer is yes, it can live in the entity.

Conclusion

CicloUrbana's code no longer just works and avoids the known mistakes: it can be read. And the criterion that governs everything is a single one, the one that opened the lesson: the audience is not the compiler, it is the next person who reads it, with the most objective property — testability — as the thermometer: if testing something requires acrobatics, the problem is the design.

You know how to name things with the course's convention — the domain in the council's words, the framework's vocabulary only where the framework lives, no hybrids — and how to tell getData() from findWithAvailability(int minimumBikes), which answers what, from where and by what criterion. You write functions with a single level of abstraction, where the public method tells the story and the private ones fill in the detail, and you know why a boolean flag is really two methods that have not been separated yet. You have seen the complete refactoring of RentalService.finish in four steps — extracting the calculation to FareSelector, turning the surcharge into the FareWithOvertimeSurcharge decorator, moving the behaviour into Rental.finish(...) and pushing the side effects out to AFTER_COMMIT — from thirty-eight lines and five reasons to change to eleven lines and one, with the non-negotiable condition of having the tests green before and after every step.

You have SOLID grounded in Ribalta with its two misunderstandings cleared up — DIP is not one interface per class, and LSP is almost always violated by contract rather than by inheritance —; the criterion for comments, which document the why and never the what; error handling with domain exceptions, without using them for the normal flow, without catching Exception and with different messages for the log and for the client; immutability with records and read-only collections, and its honest limit in JPA entities, where the answer is not immutability but controlled mutation through methods with business names; and Optional in its one correct place, the return value, with orElseThrow replacing the .get() that turns a 404 into a 500. And you have the honest discussion about the anaemic model, with CicloUrbana's criterion as a defensible middle ground: rules that depend only on the state of the aggregate itself go in the entity, those that need collaborators or cross aggregates go in the service.

Closing the lesson are the three pieces that turn all of the above into something that holds itself up: ArchUnit, which turns the dependency rules into tests that go red with a message explaining why; Spotless, Checkstyle and static analysis, which take style out of reviews so that reviews can deal with what no tool can see; and safe refactoring supported by module 6, with the campsite rule as the sustainable way to improve and with technical debt recorded with an owner, a cost and an impact instead of with decorative TODOs — and with explicit permission to leave unpaid the debt that bothers nobody.

That closes the reflection on how CicloUrbana is made. Across ten modules we have built the Ribalta network layer by layer and we have never looked at it whole. The lesson Final Project: A Complete Tour of CicloUrbana does exactly that: the final architecture in one diagram, the complete repository structure, the journey of a POST /api/v1/rentals step by step from the load balancer to the metric — citing at each step the lesson where it was studied —, the map of what each module built, the design decisions with their alternatives and trade-offs, the final pom.xml and application.yml annotated, how to get the whole project running from scratch, and where to take it next.

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