A reservation service often starts small. It receives a request, calculates a price, stores a booking, and sends a confirmation. Over time, more conditions arrive: children receive discounts, premium customers use another pricing rule, invoices require tax identifiers, and new storage or notification providers are introduced.
The first version may still work, but every change becomes risky. Developers hesitate before touching the class because one method performs several jobs, input values are represented by generic types, and changing a dependency requires editing business logic. The problem is not that the code fails today. The problem is that the design makes tomorrow's work slower and less predictable.
This tutorial walks through a practical redesign of a Java reservation workflow. The goal is not to add features or optimize performance. The goal is to make the code easier to understand, safer to use, easier to test, and easier to extend.
The Problem
Assume an application contains a service like this:
public class ReservationManager {
public String create(
String customerName,
String customerEmail,
double amount,
String currency,
int adults,
int children,
boolean premiumCustomer) {
double finalAmount = amount;
if (premiumCustomer) {
finalAmount = finalAmount * 0.90;
}
if (children > 0) {
finalAmount = finalAmount - (children * 5);
}
ReservationEntity entity = new ReservationEntity();
entity.setCustomerName(customerName);
entity.setCustomerEmail(customerEmail);
entity.setAmount(finalAmount);
entity.setCurrency(currency);
entity.setAdults(adults);
entity.setChildren(children);
DatabaseReservationRepository repository =
new DatabaseReservationRepository();
String reservationId = repository.save(entity);
EmailClient client = new EmailClient();
client.send(
customerEmail,
"Reservation " + reservationId + " created");
return reservationId;
}
}
This code is not broken in an obvious way. It can compile, run, and return an identifier. The difficulties appear when requirements change.
The method is responsible for:
- interpreting customer information
- calculating prices
- applying discounts
- constructing persistence data
- creating infrastructure dependencies
- storing the reservation
- sending a confirmation
- formatting a message
It also accepts several primitive or generic values that can be misused:
amountcan be negativecurrencycan contain an unsupported valueadultsandchildrencan be passed in the wrong ordercustomerEmailcan be empty- pricing logic is mixed with orchestration
- the service is tied directly to a database repository and email client
A good redesign should improve these properties without making the code unnecessarily complicated.
What Good Code Should Provide
In this workflow, good code should be:
- readable enough that another developer can follow the reservation flow quickly
- reliable enough that method names and return values do what callers expect
- difficult to misuse through invalid or ambiguous inputs
- modular enough that pricing, storage, and notification can change independently
- reusable where the same concepts appear elsewhere
- explicit about assumptions and constraints
- free from unnecessary mutation and hidden side effects
These goals are connected. Better names improve readability. Better domain types reduce misuse. Smaller responsibilities improve modularity. Dependency injection reduces coupling. Immutability prevents accidental state changes.
Step 1: Replace Ambiguous Inputs with Domain Types
The original method receives eight separate parameters. Some values belong together, but the method signature does not communicate those relationships.
A price is not only a number. It is a number combined with a currency. A group size is not two unrelated integers. It is one concept containing adult and child counts.
Create dedicated types for these concepts.
import java.math.BigDecimal;
import java.util.Objects;
public final class Money {
private final BigDecimal amount;
private final String currency;
public Money(BigDecimal amount, String currency) {
this.amount = Objects.requireNonNull(amount);
this.currency = Objects.requireNonNull(currency);
if (amount.signum() < 0) {
throw new IllegalArgumentException(
"Amount must not be negative");
}
if (currency.isBlank()) {
throw new IllegalArgumentException(
"Currency must not be blank");
}
}
public BigDecimal amount() {
return amount;
}
public String currency() {
return currency;
}
}
Now define the party size.
public final class PartySize {
private final int adultCount;
private final int childCount;
public PartySize(int adultCount, int childCount) {
if (adultCount < 1) {
throw new IllegalArgumentException(
"At least one adult is required");
}
if (childCount < 0) {
throw new IllegalArgumentException(
"Child count must not be negative");
}
this.adultCount = adultCount;
this.childCount = childCount;
}
public int adultCount() {
return adultCount;
}
public int childCount() {
return childCount;
}
public int totalGuests() {
return adultCount + childCount;
}
}
These classes provide several benefits:
- Related values travel together.
- Validation happens when the object is created.
- Invalid states are rejected early.
- Method signatures become shorter.
- The code communicates business meaning directly.
This is more than cosmetic clean-up. The compiler can now help distinguish a price from a guest count, even if both eventually contain numeric values.
Step 2: Make Names Explain Intent
Names such as create, process, handle, or manage often hide too much. A reader should not need to inspect an entire method to understand its purpose.
The service method can be renamed to describe the workflow:
public ReservationId placeReservation(
Customer customer,
PartySize partySize,
Money basePrice) {
// orchestration
}
The return type can also express intent.
import java.util.Objects;
public final class ReservationId {
private final String value;
public ReservationId(String value) {
this.value = Objects.requireNonNull(value);
if (value.isBlank()) {
throw new IllegalArgumentException(
"Reservation id must not be blank");
}
}
public String value() {
return value;
}
}
Returning ReservationId is clearer than returning an arbitrary String. A caller immediately knows what the value represents.
Readable code is not necessarily the code with the fewest lines. Short code can still be difficult to understand. Prefer names that reveal purpose over compressed expressions that require mental decoding.
Step 3: Separate Responsibilities
The original class changes for many unrelated reasons:
- pricing rules change
- persistence changes
- email delivery changes
- customer validation changes
- reservation creation changes
A class with several reasons to change usually has too many responsibilities.
Split the workflow into focused components.
public interface PricingPolicy {
Money calculatePrice(
Money basePrice,
PartySize partySize,
Customer customer);
}
public interface ReservationRepository {
ReservationId save(Reservation reservation);
}
public interface ReservationNotifier {
void sendConfirmation(
Customer customer,
ReservationId reservationId);
}
Each interface describes one role. The main service can coordinate them without knowing implementation details.
public final class ReservationService {
private final PricingPolicy pricingPolicy;
private final ReservationRepository repository;
private final ReservationNotifier notifier;
public ReservationService(
PricingPolicy pricingPolicy,
ReservationRepository repository,
ReservationNotifier notifier) {
this.pricingPolicy = pricingPolicy;
this.repository = repository;
this.notifier = notifier;
}
public ReservationId placeReservation(
Customer customer,
PartySize partySize,
Money basePrice) {
Money finalPrice = pricingPolicy.calculatePrice(
basePrice,
partySize,
customer);
Reservation reservation = new Reservation(
customer,
partySize,
finalPrice);
ReservationId reservationId =
repository.save(reservation);
notifier.sendConfirmation(customer, reservationId);
return reservationId;
}
}
The service now reads as a sequence of business actions:
- calculate the final price
- create the reservation
- store it
- notify the customer
- return the identifier
That is the level of detail an orchestration service should expose.
Step 4: Depend on Abstractions Instead of Concrete Classes
The earlier implementation created infrastructure dependencies inside the method:
DatabaseReservationRepository repository =
new DatabaseReservationRepository();
This ties business logic to a specific persistence mechanism. Replacing the database repository with another implementation requires editing the service. Testing also becomes harder because the service always creates the real dependency.
Constructor injection solves this problem. The service receives the dependencies it needs and depends on interfaces rather than concrete implementations.
The production composition can still use concrete classes:
ReservationRepository repository =
new DatabaseReservationRepository();
ReservationNotifier notifier =
new EmailReservationNotifier();
PricingPolicy pricingPolicy =
new StandardReservationPricing();
ReservationService service = new ReservationService(
pricingPolicy,
repository,
notifier);
A different environment can choose different implementations without changing ReservationService.
ReservationRepository repository =
new InMemoryReservationRepository();
ReservationNotifier notifier =
new RecordingReservationNotifier();
ReservationService service = new ReservationService(
new StandardReservationPricing(),
repository,
notifier);
The high-level business workflow no longer depends directly on low-level infrastructure details. Both sides depend on contracts.
Step 5: Use Small, Client-Focused Interfaces
Large interfaces create unnecessary coupling. Imagine a single interface like this:
public interface ReservationPlatform {
ReservationId save(Reservation reservation);
Money calculatePrice(ReservationDraft draft);
void sendConfirmation(Customer customer);
void exportMonthlyReport();
void cancelExpiredReservations();
}
Any implementation must handle methods that may have nothing to do with its actual role. A pricing component should not know about report exports. A notification component should not implement persistence.
Prefer several focused interfaces:
public interface PricingPolicy {
Money calculatePrice(
Money basePrice,
PartySize partySize,
Customer customer);
}
public interface ReservationRepository {
ReservationId save(Reservation reservation);
}
public interface ReservationNotifier {
void sendConfirmation(
Customer customer,
ReservationId reservationId);
}
Small interfaces make dependencies more precise. They also reduce the temptation to create generic classes named Manager, Helper, or Utility that gradually absorb unrelated behaviour.
Step 6: Extend Pricing Without Editing the Service
A common maintenance problem appears when every new pricing rule adds another condition to a central method.
if (customer.isPremium()) {
// premium calculation
} else if (customer.isCorporate()) {
// corporate calculation
} else if (customer.hasVoucher()) {
// voucher calculation
}
As the number of customer types grows, the method becomes harder to maintain. Every extension modifies existing logic and increases the chance of breaking another branch.
Move each rule behind the same interface.
import java.math.BigDecimal;
public final class StandardReservationPricing
implements PricingPolicy {
@Override
public Money calculatePrice(
Money basePrice,
PartySize partySize,
Customer customer) {
BigDecimal childReduction = BigDecimal
.valueOf(partySize.childCount())
.multiply(BigDecimal.valueOf(5));
BigDecimal adjusted = basePrice
.amount()
.subtract(childReduction)
.max(BigDecimal.ZERO);
return new Money(adjusted, basePrice.currency());
}
}
import java.math.BigDecimal;
public final class PremiumReservationPricing
implements PricingPolicy {
@Override
public Money calculatePrice(
Money basePrice,
PartySize partySize,
Customer customer) {
BigDecimal discounted = basePrice
.amount()
.multiply(BigDecimal.valueOf(0.90));
return new Money(discounted, basePrice.currency());
}
}
The reservation service does not change when another pricing policy is introduced. It only calls the contract.
This design is open to new implementations while keeping stable business orchestration closed to repeated modification.
Step 7: Avoid Inheritance That Cannot Honour Its Contract
Inheritance is useful only when a subtype can safely replace its parent.
Suppose the application defines this abstraction:
public abstract class NotificationChannel {
public abstract void sendEmail(String address, String message);
}
A text message implementation cannot honor that contract naturally:
public final class SmsChannel extends NotificationChannel {
@Override
public void sendEmail(String address, String message) {
throw new UnsupportedOperationException(
"SMS does not send email");
}
}
The inheritance hierarchy is wrong because the subtype cannot be used wherever the parent is expected.
Use a capability-focused contract instead:
public interface ReservationNotifier {
void sendConfirmation(
Customer customer,
ReservationId reservationId);
}
Both email and SMS implementations can fulfil that contract in their own way. The abstraction describes the business capability rather than one implementation detail.
Step 8: Prefer Immutable Domain Objects
Mutable objects make it difficult to reason about state. A method may receive a reservation, modify it, and return nothing. The caller then has to know that the input object changed as a side effect.
A safer design creates objects in a valid state and does not expose setters.
import java.util.Objects;
public final class Customer {
private final String name;
private final String email;
private final CustomerTier tier;
public Customer(
String name,
String email,
CustomerTier tier) {
this.name = requireText(name, "Customer name");
this.email = requireText(email, "Customer email");
this.tier = Objects.requireNonNull(tier);
}
public String name() {
return name;
}
public String email() {
return email;
}
public CustomerTier tier() {
return tier;
}
private static String requireText(
String value,
String fieldName) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(
fieldName + " must not be blank");
}
return value;
}
}
The object cannot be partially initialized and then repaired later. Once construction succeeds, the instance is valid according to its rules.
Immutability also helps with concurrency because shared objects cannot be modified unexpectedly by another thread.
Step 9: Use a Builder for Complex Construction
Some objects contain many optional values. A constructor with ten parameters is difficult to read, especially when several arguments have the same type.
A builder can make construction clearer while keeping the final object immutable.
public final class ReservationDraftBuilder {
private Customer customer;
private PartySize partySize;
private Money basePrice;
private String note;
public ReservationDraftBuilder forCustomer(Customer customer) {
this.customer = customer;
return this;
}
public ReservationDraftBuilder withPartySize(
PartySize partySize) {
this.partySize = partySize;
return this;
}
public ReservationDraftBuilder withBasePrice(
Money basePrice) {
this.basePrice = basePrice;
return this;
}
public ReservationDraftBuilder withNote(String note) {
this.note = note;
return this;
}
public ReservationDraft build() {
return new ReservationDraft(
customer,
partySize,
basePrice,
note);
}
}
Usage becomes self-explanatory:
ReservationDraft draft = new ReservationDraftBuilder()
.forCustomer(customer)
.withPartySize(new PartySize(2, 1))
.withBasePrice(
new Money(
new BigDecimal("245.00"),
"SEK"))
.withNote("Window table requested")
.build();
The builder is mutable during construction, but the resulting object should be immutable. This limits mutation to one controlled place.
Step 10: Keep One Source of Truth for Data and Logic
Duplicate data can become inconsistent.
Assume a reservation stores:
- arrival time
- departure time
- total duration
If duration can always be calculated from the two timestamps, storing it separately creates another value that must be updated whenever either timestamp changes.
The same issue applies to business logic. If price calculation appears in controllers, services, background jobs, and reporting code, the rules will eventually diverge.
Centralize the calculation behind one component:
public final class ReservationDurationCalculator {
public Duration calculate(
Instant start,
Instant end) {
if (end.isBefore(start)) {
throw new IllegalArgumentException(
"End time must not be before start time");
}
return Duration.between(start, end);
}
}
The exact class is less important than the principle: one rule should have one authoritative implementation.
This reduces duplication, simplifies maintenance, and makes changes easier to verify.
Step 11: Do Not Leak Infrastructure Details
A service API should not force callers to understand a storage provider.
Avoid this:
public StorageVendorResponse saveReceipt(
ReservationReceipt receipt) {
// vendor-specific implementation
}
The return type exposes the chosen vendor. Every caller becomes coupled to that decision.
Return an application-level type instead:
public ReceiptLocation saveReceipt(
ReservationReceipt receipt) {
// implementation hidden
}
The storage implementation can change without forcing updates throughout the business layer.
The same principle applies to method behavior. Do not make callers coordinate several low-level operations when the service can handle them internally.
Instead of:
if (receiptStore.exists(receiptId)) {
receiptStore.overwrite(receipt);
} else {
receiptStore.create(receipt);
}
Prefer an operation that reflects the actual intent:
receiptStore.save(receipt);
The implementation decides whether the operation creates or replaces data.
Step 12: Keep Object Navigation Under Control
Long chains expose internal object structure:
String cityName = reservation
.customer()
.contactDetails()
.postalAddress()
.city()
.name();
The calling code now knows every level of the object graph. A structural change can break many callers.
Expose behavior at an appropriate level:
String cityName = reservation.customerCityName();
Do not apply this mechanically. Too many forwarding methods can create classes that only delegate work. The aim is to reduce unnecessary knowledge, not to hide every interaction.
A useful question is: should this caller understand the complete internal path, or does it only need the final business value?
Step 13: Make Assumptions Explicit
Some assumptions are unavoidable. Problems arise when they remain hidden.
Suppose reporting code treats a larger reservation identifier as proof that the reservation is newer:
reservations.sort(
Comparator.comparing(Reservation::id).reversed());
This works only while identifiers increase in creation order. If the identifier strategy changes, the report becomes incorrect.
Use the actual concept:
reservations.sort(
Comparator.comparing(
Reservation::createdAt).reversed());
When an assumption is required, enforce it in one of two ways:
- make invalid usage impossible through the type system or construction API
- detect violations immediately and fail with a clear error
Do not rely on undocumented conventions such as list positions, magic string values, identifier ordering, or implicit initialization steps.
Applying KISS, DRY, and YAGNI
Three practical habits help prevent accidental complexity.
Keep It Simple
Choose the simplest design that clearly solves the current problem. Simplicity does not mean placing everything in one method. It means avoiding complexity that has no present value.
A focused service with three clear dependencies is often simpler than one large class with hidden behaviour.
Do Not Repeat Yourself
Repeated code creates repeated maintenance. Extract shared logic when duplication represents the same business rule.
Do not remove every visual similarity. Two blocks can look alike while representing different concepts. Share logic only when the meaning is also shared.
You Are Not Gonna Need It
Do not create extension points for imagined future requirements. Add abstractions where real variation already exists or where a clear requirement demands it.
For example, introducing PricingPolicy makes sense when the application already supports multiple pricing behaviours. Building a plugin framework for dozens of hypothetical pricing engines would be unnecessary.
Common Mistakes
Treating Short Code as Good Code
A one-line expression can be harder to understand than several clearly named steps. Optimize for comprehension, not minimum line count.
Creating Generic Utility Classes
Classes named CommonUtils, ReservationHelper, or GeneralManager often become dumping grounds. Prefer classes named after one responsibility.
Passing Entire Objects Everywhere
A method should receive only what it needs. Passing a large object increases coupling and makes hidden dependencies harder to see.
Returning Mutable Internal Collections
If callers can modify an internal list directly, they can change object state without going through validation. Return an immutable view or a defensive copy when necessary.
Applying SOLID Mechanically
The SOLID principles are design guidance, not a requirement to create an interface for every class. Too many abstractions can make a small application harder to follow.
Hiding Every Dependency Behind a Wrapper
Abstraction is valuable when it protects the application from change or clarifies intent. A wrapper that adds no meaning can become unnecessary indirection.
Allowing the First Mess to Become the Standard
One ignored shortcut often makes the next shortcut easier to justify. Inconsistent naming, duplicated logic, missing validation, and weak boundaries accumulate into technical debt.
Fix small quality problems before they become the accepted style of the codebase.
Practical Refactoring Workflow
Use this sequence when improving a fragile service:
- Read the existing flow and identify the business outcome.
- Rename unclear variables and methods.
- Group related parameters into domain types.
- Add validation at object construction boundaries.
- Separate pricing, persistence, notification, and orchestration.
- Introduce interfaces only where variation or decoupling is useful.
- Inject dependencies instead of constructing them inside business methods.
- Remove hidden side effects and prefer immutable objects.
- centralize repeated business rules.
- Replace infrastructure-specific return types with application types.
- Identify assumptions and enforce them explicitly.
- Review whether every abstraction still makes the code easier to understand.
Make changes in small steps. A redesign is safer when each step has one clear purpose.
Checklist
Before considering the reservation workflow maintainable, verify the following:
- Method and class names reveal business intent.
- Related values are represented by domain types.
- Invalid values are rejected early.
- Classes have focused responsibilities.
- High-level logic depends on abstractions where appropriate.
- Interfaces contain only the operations their clients need.
- New pricing rules can be added without editing unrelated code.
- Subtypes can honor the contracts of their parent abstractions.
- Domain objects avoid unnecessary mutation.
- Shared business rules have one authoritative implementation.
- Infrastructure details do not leak into the business API.
- Callers do not depend on deep object graphs.
- Hidden assumptions have been removed or enforced.
- The design solves current requirements without speculative complexity.
Conclusion
Good coding habits are not separate from delivery speed. They determine how quickly a codebase can absorb the next requirement.
A Java service becomes fragile when meaning is hidden inside generic values, responsibilities are mixed, dependencies are hardcoded, and state can change unexpectedly. The remedy is not a massive framework or a complete rewrite. It is a sequence of disciplined design choices.
Use clear names. Model the domain with meaningful types. Keep responsibilities focused. Depend on contracts where change is expected. Prefer immutable state. Centralize business rules. Make assumptions visible.
When these habits become part of everyday development, the codebase remains easier to understand, safer to modify, and less likely to turn every new feature into a risky operation.