You are maintaining a Java booking service that searches trips, prices them, and returns offers to a client. The service works, but every change feels risky. A traveler breakdown is represented as a List<Integer>. A cabin tier is a String. A duration is an Integer, with a comment saying it is stored in minutes. A request object has setters, so it can be modified after validation. Pricing is hardcoded inside the search service.
None of these choices is obviously broken in isolation. The problem is that they make the code easy to misuse. A caller can pass [1, 2] instead of [1, 2, 0], swap cabin and transport constants, pass seconds where minutes are expected, or modify a request after the service has already checked it.
Adding more runtime validation helps, but it does not solve the design problem. The better fix is to shape the code so the wrong call is harder to write in the first place. Good Java code should communicate intent, keep related data together, expose clear contracts, avoid surprising side effects, and make invalid states difficult to create.
This article walks through a practical refactor of a booking workflow. The goal is not to make the code clever. The goal is to make it boring, readable, and reliable.
The Problem
The service receives a search request and returns priced trip offers. In a simplified system, the main parts are:
Client request
|
v
TripSearchService
|
+-- traveler data
+-- cabin tier
+-- journey duration logic
+-- pricing logic
|
v
Priced offers
The inputs are:
- Origin and destination
- Traveler counts for adults, children, and infants
- Cabin tier
- Optional timing constraints, such as maximum layover duration
The output is a list of offers with prices and itinerary information.
The service has three practical constraints:
- Developers should understand request fields without reading hidden conventions.
- Callers should not be able to create obviously invalid or ambiguous data structures.
- Changing pricing, duration logic, or supported trip types should not require editing unrelated code.
Here is a small version of the kind of request model that causes trouble:
final class TripSearchRequest {
private List<Integer> travelerCounts;
private String cabinTier;
private Integer maximumLayover;
public List<Integer> getTravelerCounts() {
return travelerCounts;
}
public void setTravelerCounts(List<Integer> travelerCounts) {
this.travelerCounts = travelerCounts;
}
public String getCabinTier() {
return cabinTier;
}
public void setCabinTier(String cabinTier) {
this.cabinTier = cabinTier;
}
public Integer getMaximumLayover() {
return maximumLayover;
}
public void setMaximumLayover(Integer maximumLayover) {
this.maximumLayover = maximumLayover;
}
}
This class is compact, but it hides too much.
What does travelerCounts.get(0) mean? Are there always three values? Is maximumLayover measured in minutes, seconds, or hours? What are the valid cabin strings? Can the request be changed halfway through processing?
The code technically accepts input, but it does not guide the caller toward correct input.
Step 1: Replace Vague Containers with Domain Types
A generic type such as List<Integer> is powerful, but sometimes too powerful. It can represent almost anything, which means it communicates almost nothing.
Instead of encoding traveler counts by position, create a small type that names the data:
final class TravelerBreakdown {
private final int adults;
private final int children;
private final int infants;
private TravelerBreakdown(int adults, int children, int infants) {
if (adults < 0 || children < 0 || infants < 0) {
throw new IllegalArgumentException("Traveler counts cannot be negative");
}
if (adults == 0 && children == 0 && infants == 0) {
throw new IllegalArgumentException("At least one traveler is required");
}
this.adults = adults;
this.children = children;
this.infants = infants;
}
public static TravelerBreakdown of(int adults, int children, int infants) {
return new TravelerBreakdown(adults, children, infants);
}
public int adults() {
return adults;
}
public int children() {
return children;
}
public int infants() {
return infants;
}
public int totalTravelers() {
return adults + children + infants;
}
}
Now the caller writes this:
TravelerBreakdown travelers = TravelerBreakdown.of(2, 1, 0);
That is much clearer than this:
List<Integer> travelers = List.of(2, 1, 0);
The benefit is not only readability. The type becomes a natural home for rules about traveler counts. If those rules change, you change one class instead of searching the codebase for every place that interprets the list.
This is the single source of truth idea applied to data and logic. Related data should work together, and the logic that depends on that data should live close to it.
Step 2: Use Standard Types for Standard Concepts
A duration should not be an Integer with a comment. Java already has a standard type for a time-based amount: Duration.
Instead of this:
private Integer maximumLayover; // minutes
use this:
private Duration maximumLayover;
The caller becomes explicit:
Duration maximumLayover = Duration.of(90, ChronoUnit.MINUTES);
Now the unit is part of the construction, not tribal knowledge hidden in a comment.
This applies beyond time. When the Java standard library or a mature library already models a concept, prefer that instead of rebuilding it with strings and numbers. Your own code should focus on the business concept, not on recreating basic building blocks.
Step 3: Replace String Constants with Enums When the Value Set Is Fixed
Cabin tier is another common source of misuse. If the service accepts strings, this can happen:
request.setCabinTier("BUSINES");
The typo compiles. The failure appears later, usually far from the place where the bad value was created.
When the valid values are known and limited, use an enum:
enum CabinTier {
ECONOMY,
PREMIUM,
BUSINESS,
FIRST
}
Then the request can use the enum directly:
final class TripSearchRequest {
private final String origin;
private final String destination;
private final TravelerBreakdown travelers;
private final CabinTier cabinTier;
private final Duration maximumLayover;
TripSearchRequest(
String origin,
String destination,
TravelerBreakdown travelers,
CabinTier cabinTier,
Duration maximumLayover
) {
this.origin = origin;
this.destination = destination;
this.travelers = travelers;
this.cabinTier = cabinTier;
this.maximumLayover = maximumLayover;
}
public TravelerBreakdown travelers() {
return travelers;
}
public CabinTier cabinTier() {
return cabinTier;
}
public Duration maximumLayover() {
return maximumLayover;
}
}
Enums improve type safety and readability. They also help your IDE and compiler catch mistakes that string constants cannot catch.
This does not mean every string should become an enum. Use enums when the set of valid values is stable and known by the code. If values are configured externally or change often, a dedicated class or lookup model may fit better.
Step 4: Make the Request Immutable
Mutable request objects are convenient at first. They are also easy to damage accidentally.
A mutable request can be validated, then changed before pricing. It can be reused across flows. It can be passed to another method that modifies it as a side effect. These bugs are frustrating because the object looks valid at one point and invalid later.
A safer approach is to make the request immutable. Set all required fields during construction, keep fields private and final, and avoid setters.
If construction has several optional values, use a builder. The builder is mutable while assembling the object. The final object is not.
final class TripSearchRequest {
private final String origin;
private final String destination;
private final TravelerBreakdown travelers;
private final CabinTier cabinTier;
private final Duration maximumLayover;
private TripSearchRequest(Builder builder) {
this.origin = builder.origin;
this.destination = builder.destination;
this.travelers = builder.travelers;
this.cabinTier = builder.cabinTier;
this.maximumLayover = builder.maximumLayover;
}
public static Builder from(String origin, String destination, TravelerBreakdown travelers) {
return new Builder(origin, destination, travelers);
}
static final class Builder {
private final String origin;
private final String destination;
private final TravelerBreakdown travelers;
private CabinTier cabinTier = CabinTier.ECONOMY;
private Duration maximumLayover = Duration.of(3, ChronoUnit.HOURS);
private Builder(String origin, String destination, TravelerBreakdown travelers) {
if (origin == null || destination == null || travelers == null) {
throw new IllegalArgumentException("Origin, destination, and travelers are required");
}
this.origin = origin;
this.destination = destination;
this.travelers = travelers;
}
public Builder cabinTier(CabinTier cabinTier) {
this.cabinTier = cabinTier;
return this;
}
public Builder maximumLayover(Duration maximumLayover) {
this.maximumLayover = maximumLayover;
return this;
}
public TripSearchRequest build() {
return new TripSearchRequest(this);
}
}
}
Usage becomes readable:
TripSearchRequest request = TripSearchRequest
.from("MXP", "SFO", TravelerBreakdown.of(2, 1, 0))
.cabinTier(CabinTier.PREMIUM)
.maximumLayover(Duration.of(2, ChronoUnit.HOURS))
.build();
This is longer than a few setters, but it is harder to misuse. The required fields are clear. Defaults are centralized. The finished request cannot be changed after construction.
Step 5: Keep Derived Data Out of Primary Storage
Booking systems often deal with derived values. A journey duration, for example, can be calculated from departure and arrival times. If you store both the primary data and the derived value, you now have two sources of truth.
That creates a maintenance risk. If a schedule changes and only one value is updated, the system can return inconsistent data.
A cleaner approach is to keep the primary data and calculate the derived value through one tested component:
final class TravelLeg {
private final ZonedDateTime departureTime;
private final ZonedDateTime arrivalTime;
TravelLeg(ZonedDateTime departureTime, ZonedDateTime arrivalTime) {
this.departureTime = departureTime;
this.arrivalTime = arrivalTime;
}
public ZonedDateTime departureTime() {
return departureTime;
}
public ZonedDateTime arrivalTime() {
return arrivalTime;
}
}
final class JourneyTimeCalculator {
public Duration durationOf(List<TravelLeg> legs) {
if (legs.isEmpty()) {
throw new IllegalArgumentException("A journey needs at least one leg");
}
TravelLeg first = legs.get(0);
TravelLeg last = legs.get(legs.size() - 1);
return Duration.between(first.departureTime(), last.arrivalTime());
}
}
If the calculation is expensive, you can add caching near this component. Avoid spreading the calculation across controllers, services, mappers, and response builders.
The practical rule is simple: store the facts, centralize the calculation, and expose the result through one clear method.
Step 6: Inject Behavior That May Change
A search service should not hardcode a pricing implementation unless that implementation is truly fixed. Pricing can vary by currency, market, customer segment, or provider. If the search service creates the concrete pricer itself, every change forces you to edit the service.
Problematic version:
final class TripSearchService {
private final EuroOfferPricer pricer = new EuroOfferPricer();
public List<PricedOffer> search(TripSearchRequest request) {
List<TripCandidate> candidates = findCandidates(request);
return pricer.price(candidates, request.travelers());
}
private List<TripCandidate> findCandidates(TripSearchRequest request) {
return List.of();
}
}
More flexible version:
interface OfferPricer {
List<PricedOffer> price(List<TripCandidate> candidates, TravelerBreakdown travelers);
}
final class TripSearchService {
private final OfferPricer pricer;
TripSearchService(OfferPricer pricer) {
this.pricer = pricer;
}
public List<PricedOffer> search(TripSearchRequest request) {
List<TripCandidate> candidates = findCandidates(request);
return pricer.price(candidates, request.travelers());
}
private List<TripCandidate> findCandidates(TripSearchRequest request) {
return List.of();
}
}
The service now depends on an interface, not a specific class. This supports the dependency inversion principle: higher-level workflow code should not be tied directly to lower-level implementation details.
The goal is not abstraction for its own sake. Use this when behavior is expected to vary or when depending on a concrete class makes the service harder to test, reuse, or change.
Step 7: Keep Interfaces Focused
A large interface usually grows because developers keep adding "just one more method." The result is a contract that forces implementers to care about methods they do not need.
Avoid an interface like this:
interface OfferManager {
long estimateDistanceInKm(TripCandidate candidate);
long estimateCo2InKg(TripCandidate candidate);
Money calculatePrice(TripCandidate candidate, TravelerBreakdown travelers);
}
Distance, emissions, and pricing may be related in the product experience, but they are different responsibilities. Split them:
interface DistanceEstimator {
long estimateDistanceInKm(TripCandidate candidate);
}
interface EmissionEstimator {
long estimateCo2InKg(TripCandidate candidate);
}
interface PriceEstimator {
Money calculatePrice(TripCandidate candidate, TravelerBreakdown travelers);
}
Now classes can implement only what they actually provide. This keeps contracts smaller, improves readability, and reduces accidental coupling between unrelated changes.
Also watch names such as Manager, Processor, and Handler. They are not always wrong, but they often hide unclear responsibility. Prefer names that say what the class does in the domain.
Step 8: Separate Queries from Commands
A method that looks like it only reads data should not also change the system. Mixing queries and commands creates surprising side effects.
Problematic version:
final class OfferSummaryService {
private final EmailGateway emailGateway;
OfferSummaryService(EmailGateway emailGateway) {
this.emailGateway = emailGateway;
}
public Money getTotalPriceAndNotify(Customer customer, PricedOffer offer) {
Money total = offer.totalPrice();
emailGateway.sendSummary(customer, offer);
return total;
}
}
The name hints that the method returns a price, but it also sends an email. A caller may invoke it twice while trying to display a value and accidentally send two emails.
Separate the operations:
final class OfferSummaryService {
private final EmailGateway emailGateway;
OfferSummaryService(EmailGateway emailGateway) {
this.emailGateway = emailGateway;
}
public Money totalPrice(PricedOffer offer) {
return offer.totalPrice();
}
public void sendSummary(Customer customer, PricedOffer offer) {
emailGateway.sendSummary(customer, offer);
}
}
The first method is a query. It returns information and does not change external state. The second is a command. It performs an action. This separation makes the service easier to reason about and safer to reuse.
Refactoring Workflow
Use this workflow when you find Java code that is easy to misuse:
- Identify the misuse risk. Look for unclear strings, positional lists, generic maps, mutable request objects, long parameter lists, and methods with surprising side effects.
- Name the domain concept. If
amountandcurrencyalways travel together, createMoney. If three traveler counts always travel together, createTravelerBreakdown. - Move rules close to the data. Put validation and small domain operations inside the type that owns the concept.
- Replace string constants with enums when the valid values are fixed.
- Replace number-plus-comment fields with standard types such as
Durationwhen Java already has a better abstraction. - Make completed objects immutable. Use constructors or builders instead of exposing setters.
- Inject behavior that varies. Depend on interfaces for pricing, storage, or provider-specific logic when those choices may change.
- Split large interfaces. Keep contracts specific to the clients that use them.
- Separate queries from commands. A method should not look like a read while secretly performing writes, notifications, or external calls.
- Review the result with another developer. Good code should be understandable to someone who did not write it.
Common Mistakes
Replacing Every Primitive with a Class
Primitive obsession is a real problem, but overcorrecting creates noise. Not every int needs a wrapper. Create a type when it carries domain meaning, has rules, is passed around with related values, or is repeatedly interpreted in different places.
Adding Validation Without Improving the API
Validation is still necessary, but it should not be the only defense. If the API accepts ambiguous data, every caller must remember the convention. Prefer APIs that make correct usage obvious.
Returning Internal Collections Directly
If a class owns a collection, do not expose the mutable list directly. Otherwise, callers can change internal state without going through the owner.
final class Journey {
private final List<TravelLeg> legs;
Journey(List<TravelLeg> legs) {
this.legs = new ArrayList<>(legs);
}
public List<TravelLeg> legs() {
return Collections.unmodifiableList(legs);
}
}
When callers need to add or remove items, provide explicit methods so the owner can protect its rules.
Hiding Assumptions in Comments
A comment such as // values are adults, children, infants is weaker than a TravelerBreakdown class. Comments can become stale. Types are checked by the compiler and used by the IDE.
Making Builders Too Permissive
A builder should make construction readable, not bypass required data. Keep mandatory fields mandatory. If a request cannot exist without origin, destination, and travelers, require them before optional builder methods are available.
Checklist
Before merging a change to a core Java service, ask these questions:
- Can a caller understand each public method without reading its implementation?
- Are related values grouped into meaningful types?
- Are fixed value sets represented with enums instead of loose strings?
- Are standard Java types used for standard concepts such as durations?
- Can the object be changed after it has been validated?
- Does any method return data while also changing external state?
- Does each interface describe one focused capability?
- Does high-level workflow code depend on abstractions when implementation details may vary?
- Is derived data calculated from one clear source of truth?
- Would a new teammate know where to change the rule next time?
Conclusion
Making code hard to misuse is one of the most practical forms of clean Java design. It reduces the number of conventions developers must remember and moves many mistakes from runtime surprises to compile-time feedback.
The refactor is not about adding patterns everywhere. It is about making intent visible. Replace vague containers with domain types. Prefer standard types for standard ideas. Centralize rules. Keep objects immutable after construction. Inject behavior that changes. Split broad interfaces. Avoid methods with hidden side effects.
When the code says what it means, future changes become less risky. That is the real value of good coding habits: they make correct work easier and incorrect work harder.