You are asked to add a new delivery type to a Java checkout system. The product owner expects a small change: show a different shipping quote, print a slightly different label, and apply a different surcharge. Then you open the code and discover that delivery logic is scattered across several classes, repeated in multiple switch statements, and hidden inside a large service that also talks to repositories, formats labels, calculates discounts, and prepares tracking text.
The risky part is not only that the code looks ugly. The risky part is that you cannot tell how many places must change. One missed branch can make the checkout quote different from the label, or make one delivery type work in test data but fail in production data. That is the practical value of code smells: they are visible signals that a design may be harder to understand, change, or test than it needs to be.
In this walkthrough, we will clean up a simplified shipping quote flow. The goal is not to rewrite the whole system. The goal is to identify the smells that make a small delivery change dangerous, then refactor toward code that keeps related behavior together and separates unrelated responsibilities.
The Problem
The system is a checkout flow for an online store. A customer submits a cart, a destination, and a delivery mode. The application must return a quote and later print a delivery label.
The relevant components are:
Checkout API
-> ShippingQuoteService
-> CustomerRepository
-> WarehouseRepository
-> RateTable
-> LabelPrinter
The expected output is simple:
- A shipping price
- A delivery mode label
- A warehouse region
- Enough information to print a label later
The code becomes risky when one class knows too much about all of those concerns. Here is a compact example of the kind of service that starts simple and then grows into a problem:
final class ShippingQuoteService {
private final CustomerRepository customerRepository;
private final WarehouseRepository warehouseRepository;
private final RateTable rateTable;
Quote createQuote(QuoteRequest request) {
Customer customer = customerRepository.findById(request.getCustomerId());
Warehouse warehouse = warehouseRepository.findByPostalCode(request.getPostalCode());
double basePrice = request.getWeightKg() * rateTable.pricePerKg();
double surcharge;
switch (request.getDeliveryMode()) {
case "STANDARD":
surcharge = basePrice * 0.05;
break;
case "EXPRESS":
surcharge = basePrice * 0.30;
break;
case "PICKUP":
surcharge = 0.0;
break;
default:
throw new IllegalArgumentException("Unknown delivery mode");
}
if (customer.getAccount().getTier().getName().equals("PREFERRED")) {
basePrice = basePrice * 0.90;
}
String regionCode = warehouse.getRegion().getCode();
return new Quote(request.getDeliveryMode(), basePrice + surcharge, regionCode);
}
String labelText(QuoteRequest request) {
switch (request.getDeliveryMode()) {
case "STANDARD":
return "Standard ground delivery";
case "EXPRESS":
return "Express delivery";
case "PICKUP":
return "Pickup at collection point";
default:
throw new IllegalArgumentException("Unknown delivery mode");
}
}
}
This code is not broken in the obvious sense. It may compile, pass a few tests, and serve real users. But it has several warning signs:
| Smell | Why it matters in this service |
|---|---|
| Duplicated code | Delivery modes are repeated in pricing and label logic. |
| Long method | createQuote retrieves data, calculates prices, checks discounts, and builds output. |
| Repeated switches | Adding a delivery type means editing every switch. |
| Primitive obsession | Delivery modes are plain strings, so typos are valid at compile time. |
| Message chain | customer.getAccount().getTier().getName() exposes internal structure. |
| Feature envy | The quote service pulls customer details and decides customer behavior itself. |
| Divergent change | Pricing, warehouse lookup, discount rules, and labels all force edits in one class. |
| Shotgun Surgery | One new delivery type requires edits across many unrelated places. |
A smell does not automatically prove the design is wrong. It tells you where to look before the next change becomes expensive.
Step 1: Replace String Codes With a Safer Type
The fastest win is to stop representing delivery modes as raw strings. A string can contain EXPRESS, Express, EXPRSS, or any other value. The compiler cannot help you.
A small enum is a better fit when the possible values are fixed and known by the application:
enum DeliveryMode {
STANDARD,
EXPRESS,
PICKUP
}
final class QuoteRequest {
private final String customerId;
private final String postalCode;
private final double weightKg;
private final DeliveryMode deliveryMode;
QuoteRequest(String customerId, String postalCode, double weightKg, DeliveryMode deliveryMode) {
this.customerId = customerId;
this.postalCode = postalCode;
this.weightKg = weightKg;
this.deliveryMode = deliveryMode;
}
DeliveryMode getDeliveryMode() {
return deliveryMode;
}
double getWeightKg() {
return weightKg;
}
String getCustomerId() {
return customerId;
}
String getPostalCode() {
return postalCode;
}
}
This does not remove every design issue, but it removes an easy misuse path. A caller can no longer pass a random string and hope the service understands it. When code is hard to misuse, many bugs never get the chance to exist.
Step 2: Replace Repeated Switches With Delivery Policies
The repeated switch is the heart of the problem. Every time a delivery mode is added, the developer must remember every switch that knows about delivery modes. That is a classic path to Shotgun Surgery.
A better design is to move delivery-specific behavior into delivery-specific classes. The quote service should ask the selected policy what to do, not inspect a type code and make every decision itself.
interface DeliveryPolicy {
DeliveryMode mode();
double surcharge(double basePrice);
String labelText();
}
final class ExpressDeliveryPolicy implements DeliveryPolicy {
@Override
public DeliveryMode mode() {
return DeliveryMode.EXPRESS;
}
@Override
public double surcharge(double basePrice) {
return basePrice * 0.30;
}
@Override
public String labelText() {
return "Express delivery";
}
}
final class StandardDeliveryPolicy implements DeliveryPolicy {
@Override
public DeliveryMode mode() {
return DeliveryMode.STANDARD;
}
@Override
public double surcharge(double basePrice) {
return basePrice * 0.05;
}
@Override
public String labelText() {
return "Standard ground delivery";
}
}
Now there is one place where express delivery behavior lives. If a new delivery type arrives, you create a new policy instead of hunting through unrelated services.
A small registry can select the right policy:
final class DeliveryPolicyRegistry {
private final List<DeliveryPolicy> policies;
DeliveryPolicyRegistry(List<DeliveryPolicy> policies) {
this.policies = policies;
}
DeliveryPolicy findByMode(DeliveryMode mode) {
for (DeliveryPolicy policy : policies) {
if (policy.mode() == mode) {
return policy;
}
}
throw new IllegalArgumentException("Unsupported delivery mode");
}
}
This keeps the lookup logic small. More importantly, it keeps business behavior out of the switch-heavy service.
Step 3: Shorten the Service by Extracting Context
The service still retrieves data, calculates a base price, applies discounts, and creates a quote. Those tasks can be named and separated.
Instead of letting one method carry all details, create a small context object that groups the data needed to quote shipping:
final class QuoteContext {
private final QuoteRequest request;
private final Customer customer;
private final Warehouse warehouse;
private final double basePrice;
QuoteContext(QuoteRequest request, Customer customer, Warehouse warehouse, double basePrice) {
this.request = request;
this.customer = customer;
this.warehouse = warehouse;
this.basePrice = basePrice;
}
DeliveryMode deliveryMode() {
return request.getDeliveryMode();
}
double discountedBasePrice() {
return basePrice * customer.shippingDiscountMultiplier();
}
String warehouseRegionCode() {
return warehouse.regionCode();
}
}
Now the service can read like a workflow:
final class ShippingQuoteService {
private final CustomerRepository customerRepository;
private final WarehouseRepository warehouseRepository;
private final RateTable rateTable;
private final DeliveryPolicyRegistry deliveryPolicies;
Quote createQuote(QuoteRequest request) {
QuoteContext context = buildContext(request);
DeliveryPolicy policy = deliveryPolicies.findByMode(context.deliveryMode());
double basePrice = context.discountedBasePrice();
double finalPrice = basePrice + policy.surcharge(basePrice);
return new Quote(
policy.labelText(),
finalPrice,
context.warehouseRegionCode()
);
}
private QuoteContext buildContext(QuoteRequest request) {
Customer customer = customerRepository.findById(request.getCustomerId());
Warehouse warehouse = warehouseRepository.findByPostalCode(request.getPostalCode());
double basePrice = request.getWeightKg() * rateTable.pricePerKg();
return new QuoteContext(request, customer, warehouse, basePrice);
}
}
This is still not perfect, but it is easier to read. The public method now tells a story:
- Build the quote context.
- Select the delivery policy.
- Apply discount and surcharge.
- Return the quote.
That is the point of extracting methods and objects. You are not only reducing line count. You are giving names to useful ideas.
Step 4: Move Behavior Away From Envious Code
Look again at the old discount logic:
if (customer.getAccount().getTier().getName().equals("PREFERRED")) {
basePrice = basePrice * 0.90;
}
The quote service knows the internal path from customer to account to tier to name. That is a message chain. It is also feature envy because the service asks another object for its data and then makes a decision that belongs closer to that data.
A better option is to ask the customer object for behavior:
final class Customer {
private final Account account;
double shippingDiscountMultiplier() {
if (account.isPreferred()) {
return 0.90;
}
return 1.0;
}
}
final class Account {
private final CustomerTier tier;
boolean isPreferred() {
return tier.isPreferred();
}
}
This hides the object chain and moves the rule toward the data it depends on. Be careful not to turn every method into a blind forwarding method. A pure forwarding method can become a middle man. The safer rule is this: expose behavior that matters to the caller, not the raw internal structure of the object graph.
Step 5: Watch the Direction of Future Change
Two smells are especially useful when planning refactoring work: divergent change and Shotgun Surgery.
Divergent change means one class keeps changing for unrelated reasons. In the original service, you would edit ShippingQuoteService when pricing changes, customer discount rules change, warehouse lookup changes, or delivery labels change. That class has too many reasons to change.
Shotgun Surgery is the opposite shape. A single business change forces tiny edits across many files. Adding a new delivery mode might require changes in quote calculation, label printing, tracking text, validation, reporting, and tests. Each edit may be small, but the total change is fragile because one missed edit can create inconsistent behavior.
Use these questions before you refactor:
- When this feature changes, which classes change together?
- Is one class changing for many unrelated reasons?
- Does one small business change require edits across the code base?
- Is duplicated code hiding the real number of places that need changes?
- Are type codes and strings forcing every caller to understand the same rules?
The refactoring target is not always smaller code. Sometimes the code grows a little because you add focused classes. That can still be a good tradeoff when the responsibilities become clearer and future changes become local.
Common Mistakes
Treating every smell as an emergency
A smell is a warning, not a verdict. A repeated switch in one small private method may be acceptable. A repeated switch copied across checkout, labels, tracking, and reporting is different. Prioritize smells that make real changes risky.
Extracting tiny classes without a reason
Splitting code can help, but splitting everything can create noise. If a class only forwards calls and adds no meaningful behavior, it may become a middle man. Extract classes when the extracted concept has a clear responsibility.
Replacing a long method with unclear method names
A long method called processQuote is hard to read. Five extracted methods called stepOne, handleData, and doLogic are not much better. The name should explain the purpose of the code, not the order in which it happens.
Leaving primitive strings in the public API
If callers still pass delivery modes as strings, the system is still easy to misuse. Type safety should start at the boundary where the application first understands the value.
Fixing duplication only by copy-pasting into a helper
A helper method is useful when it represents one idea. A dumping ground helper class can become a god object. If the helper starts collecting unrelated methods, you have moved the smell instead of removing it.
Checklist
Use this checklist when a small Java change feels larger than it should:
- Search for repeated code before editing it.
- Look for long methods that mix retrieval, calculation, validation, and formatting.
- Replace raw strings or numbers with enums or small domain objects when the values have meaning.
- Replace repeated switches with polymorphism when behavior varies by type.
- Move behavior closer to the data it uses.
- Hide message chains behind meaningful methods.
- Avoid creating middle men that only forward calls.
- Split god classes by responsibility, not by random line count.
- Check whether future changes will be local or scattered.
Conclusion
Code smells matter because they turn small changes into uncertain changes. In the shipping quote service, the immediate task was adding a delivery type, but the real problem was scattered delivery behavior, primitive strings, long methods, and a service with too many responsibilities.
The practical fix is incremental: make invalid values harder to pass, collect related behavior into focused policies, extract workflow steps with clear names, and move decisions toward the objects that own the data. The result is not just cleaner Java. It is code that gives the next developer fewer places to check and fewer ways to make a hidden mistake.