A checkout service can look acceptable until the business asks for one small pricing change. A developer adds a new customer tier, then discovers that the same condition must be updated in the cart service, invoice generator, receipt formatter, discount validator, and reporting mapper. The change is simple, but the codebase turns it into a multi-file operation.
That pattern is a warning. The problem is not only the number of files. The deeper issue is that the business rule has no clear home. Logic is duplicated, responsibilities are mixed, and classes know too much about one another. Every modification becomes harder to understand and easier to implement inconsistently.
This tutorial uses a Java checkout workflow to show how common code smells reveal that design problem. The goal is not to replace the entire application. The goal is to move from broad, risky edits to small changes that have one obvious location.
The Problem
Assume an online store calculates a final price, stores the order, creates a receipt, and sends a confirmation. The original implementation has grown into one central class:
public class CheckoutManager {
public String checkout(
String customerType,
String currency,
double subtotal,
int itemCount,
boolean expressDelivery,
String email) {
double discount = 0;
switch (customerType) {
case "PREMIUM":
discount = subtotal * 0.10;
break;
case "BUSINESS":
discount = subtotal * 0.15;
break;
default:
discount = 0;
}
double deliveryFee = expressDelivery ? 12.0 : 4.0;
double finalPrice = subtotal - discount + deliveryFee;
OrderEntity entity = new OrderEntity();
entity.setCustomerType(customerType);
entity.setCurrency(currency);
entity.setSubtotal(subtotal);
entity.setDiscount(discount);
entity.setDeliveryFee(deliveryFee);
entity.setFinalPrice(finalPrice);
entity.setItemCount(itemCount);
entity.setEmail(email);
String orderId = new OrderRepository().save(entity);
String receipt = "Order " + orderId
+ ", total " + finalPrice
+ " " + currency;
new EmailSender().send(email, receipt);
return orderId;
}
}
This method performs several jobs:
- interprets the customer type
- selects a discount rule
- calculates delivery cost
- calculates the final amount
- creates a persistence object
- stores the order
- formats a receipt
- sends an email
The code works, but it already contains several smells:
| Smell | Symptom in the checkout workflow |
|---|---|
| Long method | Pricing, persistence, formatting, and notification are mixed |
| Primitive obsession | Customer type, currency, money, and email are generic values |
| Repeated switch | Every customer tier requires another branch |
| Large class risk | New checkout responsibilities keep accumulating |
| Feature envy | External services may calculate values from another object's data |
| Divergent change | The same class changes for pricing, storage, and messaging reasons |
| Shotgun Surgery | One rule update must be repeated across multiple files |
| God object | One service eventually controls most of the workflow |
A code smell does not prove that code is wrong. It is evidence that the design deserves investigation. The important question is not, "Can I identify a named smell?" It is, "Why is this change harder than the business requirement suggests?"
Start with the Change That Hurts
Do not begin by splitting classes randomly. Start with a real maintenance task.
Suppose the business introduces a PARTNER customer tier with a different discount. Search the codebase and record every place that must change.
You may find logic like this in several components:
if ("PREMIUM".equals(customerType)) {
price = price * 0.90;
} else if ("BUSINESS".equals(customerType)) {
price = price * 0.85;
}
The receipt generator may contain another version:
String label = switch (customerType) {
case "PREMIUM" -> "Premium discount";
case "BUSINESS" -> "Business discount";
default -> "Standard price";
};
The reporting code may repeat the same type names again. This is Shotgun Surgery: one conceptual change requires edits across many locations.
Before refactoring, write down the desired result:
Business change:
Add or modify one customer pricing rule.
Desired code impact:
Change one pricing component and its focused verification.
Unwanted impact:
Editing checkout orchestration, persistence, receipts, and unrelated classes.
This gives the refactoring a measurable direction.
Remove Duplicated Business Knowledge
Duplicated lines are easy to spot, but duplicated knowledge is more important.
Two blocks do not need to be textually identical to represent the same rule. One method may multiply by 0.90, another may subtract ten percent, and a third may print a premium label. If all three depend on the meaning of the same customer tier, they share business knowledge.
Create one abstraction that owns pricing behaviour:
public interface DiscountPolicy {
Money applyTo(Money subtotal);
String description();
}
Each customer rule becomes an implementation:
public final class PremiumDiscountPolicy
implements DiscountPolicy {
@Override
public Money applyTo(Money subtotal) {
return subtotal.multiplyBy(0.90);
}
@Override
public String description() {
return "Premium customer discount";
}
}
public final class BusinessDiscountPolicy
implements DiscountPolicy {
@Override
public Money applyTo(Money subtotal) {
return subtotal.multiplyBy(0.85);
}
@Override
public String description() {
return "Business customer discount";
}
}
The exact number of classes is less important than the ownership decision. Discount rules now live together instead of leaking into every consumer.
This also removes repeated switches from the workflow. A switch is not automatically bad. Repeated switches over the same type are the warning because every new variant forces several existing branches to change.
Replace Primitive Obsession with Domain Types
The first method accepts String currency, String customerType, and double subtotal. These values are easy to pass incorrectly and difficult to validate consistently.
A monetary amount is not merely a double. It combines an amount with a currency and supports monetary operations. A customer tier is not an arbitrary string. It belongs to a known set of values.
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 Money add(Money other) {
requireSameCurrency(other);
return new Money(
amount.add(other.amount),
currency);
}
public Money subtract(Money other) {
requireSameCurrency(other);
return new Money(
amount.subtract(other.amount),
currency);
}
public Money multiplyBy(double factor) {
return new Money(
amount.multiply(BigDecimal.valueOf(factor)),
currency);
}
public BigDecimal amount() {
return amount;
}
public String currency() {
return currency;
}
private void requireSameCurrency(Money other) {
if (!currency.equals(other.currency)) {
throw new IllegalArgumentException(
"Currencies must match");
}
}
}
Represent the known customer tiers explicitly:
public enum CustomerTier {
STANDARD,
PREMIUM,
BUSINESS,
PARTNER
}
This removes several failure paths:
- misspelled type names
- blank currency values
- mixed-currency calculations
- negative amounts entering the workflow unnoticed
- unclear method signatures containing unrelated primitives
The new types also create a natural location for behaviour. Money operations belong to Money, not in every service that happens to calculate a price.
Break the Long Method into Business Steps
A long method is difficult because the reader must hold several concerns in memory at once. Extracting methods helps only when each extracted method represents a meaningful step.
A first clean-up might look like this:
public OrderId checkout(CheckoutRequest request) {
Money discountedSubtotal =
applyDiscount(request);
Money deliveryFee =
calculateDeliveryFee(request);
Money finalPrice =
discountedSubtotal.add(deliveryFee);
Order order =
createOrder(request, finalPrice);
OrderId orderId =
orderRepository.save(order);
sendConfirmation(order, orderId);
return orderId;
}
This is easier to read, but extraction alone does not finish the design. If all helper methods remain in one growing class, the class can still become a god object.
Use the extracted steps to discover responsibilities:
- discount selection belongs to pricing
- delivery fee calculation belongs to delivery pricing
- order construction belongs to the domain workflow
- persistence belongs to a repository
- confirmation belongs to a notifier
The method is now a map of the workflow. The next step is to move each behaviour to the object that should own it.
Move Feature-Envious Behaviour
Feature envy appears when a method spends more time reading another object's data than working with its own state.
Consider this delivery calculation:
public Money calculateDeliveryFee(CheckoutRequest request) {
if (request.delivery().isExpress()
&& request.delivery().country().equals("SE")) {
return new Money(
new BigDecimal("12.00"),
request.subtotal().currency());
}
return new Money(
new BigDecimal("4.00"),
request.subtotal().currency());
}
The method is interested almost entirely in DeliverySelection. That is a sign that the behaviour may belong closer to that data.
public final class DeliverySelection {
private final DeliveryMode mode;
private final String country;
public DeliverySelection(
DeliveryMode mode,
String country) {
this.mode = mode;
this.country = country;
}
public Money feeFor(String currency) {
BigDecimal amount =
isExpressInSweden()
? new BigDecimal("12.00")
: new BigDecimal("4.00");
return new Money(amount, currency);
}
private boolean isExpressInSweden() {
return mode == DeliveryMode.EXPRESS
&& "SE".equals(country);
}
}
Now the checkout service asks the object for the result instead of reconstructing its rules elsewhere.
Moving behaviour toward the data it uses often reduces coupling. It also keeps future changes local because the rule and its required information change together.
Separate Divergent Responsibilities
Divergent change happens when one class is repeatedly modified for unrelated reasons.
The original CheckoutManager changes when:
- discount rules change
- delivery pricing changes
- persistence changes
- receipt formatting changes
- email behaviour changes
That is a strong sign that several contexts have been mixed into one class.
Split the collaborators by responsibility:
public final class CheckoutService {
private final DiscountPolicyResolver discountPolicies;
private final OrderRepository orderRepository;
private final OrderNotifier orderNotifier;
public CheckoutService(
DiscountPolicyResolver discountPolicies,
OrderRepository orderRepository,
OrderNotifier orderNotifier) {
this.discountPolicies = discountPolicies;
this.orderRepository = orderRepository;
this.orderNotifier = orderNotifier;
}
public OrderId checkout(CheckoutRequest request) {
DiscountPolicy policy =
discountPolicies.forTier(
request.customerTier());
Money discountedSubtotal =
policy.applyTo(request.subtotal());
Money deliveryFee =
request.delivery()
.feeFor(
request.subtotal().currency());
Money finalPrice =
discountedSubtotal.add(deliveryFee);
Order order = new Order(
request.customer(),
request.items(),
finalPrice,
policy.description());
OrderId orderId =
orderRepository.save(order);
orderNotifier.confirm(order, orderId);
return orderId;
}
}
The service still coordinates the use case, but it no longer contains every implementation detail.
A healthy orchestration class is allowed to call collaborators. The smell appears when it also performs all their work, owns unrelated state, and becomes the mandatory edit point for every requirement.
Recognize a God Object Before It Becomes Untouchable
A god object is usually the final form of several smaller smells. It starts as a convenient central service and gradually collects methods, fields, dependencies, and business rules.
Common symptoms include:
- the constructor requires many unrelated collaborators
- the class contains methods for pricing, validation, storage, formatting, and messaging
- developers modify it for nearly every feature
- tests require large amounts of setup
- methods use different subsets of the class fields
- changes have unpredictable effects on unrelated behaviour
- duplicated logic appears because nobody understands the full class
Do not split a god object by line count alone. Follow the domain.
Group methods that work with the same data and change for the same reason. Extract those groups into focused classes. Keep dependencies explicit, and let the old class become a small coordinator before deleting or deprecating it.
A practical target structure for the checkout workflow is:
CheckoutService
|
+-- DiscountPolicyResolver
| +-- StandardDiscountPolicy
| +-- PremiumDiscountPolicy
| +-- BusinessDiscountPolicy
| +-- PartnerDiscountPolicy
|
+-- DeliverySelection
|
+-- OrderRepository
|
+-- OrderNotifier
The structure is not valuable because it has more classes. It is valuable because each business change has a clearer destination.
Avoid Creating the Opposite Smell
Refactoring one smell can produce another when taken too far.
Middle Man
A middle man is a class or method that only forwards calls and adds no useful abstraction.
public Money getDeliveryFee(CheckoutRequest request) {
return deliveryPricing.getFee(request);
}
If this method contributes no business meaning, validation, or boundary protection, callers may be better served by using deliveryPricing directly.
Message Chain
Removing every forwarding method can expose deep object navigation:
String city = order
.customer()
.profile()
.address()
.city()
.name();
The caller now depends on the entire internal structure. A change to any intermediate object can break it.
A better operation may be:
String city = order.deliveryCityName();
There is no universal rule that delegation is always bad or always good. Prefer the option that reduces unnecessary knowledge and keeps business intent visible.
Fix Shotgun Surgery by Consolidating Change
Return to the original task: adding a PARTNER tier.
Before refactoring, the change touched switches and conditions across several classes. After the redesign, the main work should be limited to a focused pricing policy and its registration:
public final class PartnerDiscountPolicy
implements DiscountPolicy {
@Override
public Money applyTo(Money subtotal) {
return subtotal.multiplyBy(0.88);
}
@Override
public String description() {
return "Partner customer discount";
}
}
The checkout service does not need another branch. The repository does not need pricing knowledge. The notifier receives an order that already contains the calculated result and description.
This is the main outcome of the refactoring: a business change now has one primary home.
A Safe Refactoring Workflow
Use small steps when cleaning a smell-heavy workflow:
- Choose one real change that is currently difficult.
- Record every class and method that the change touches.
- Identify duplicated knowledge, not only duplicated lines.
- Rename unclear variables and methods.
- Replace related primitive values with domain types.
- Extract meaningful steps from long methods.
- Move behaviour toward the data it uses.
- Separate responsibilities that change for unrelated reasons.
- Replace repeated type switches with polymorphic behaviour where appropriate.
- Remove unnecessary delegation without exposing deep object structures.
- Repeat the original change analysis and compare the affected locations.
- Keep behaviour covered by tests before attempting a large structural split.
Refactoring should preserve externally visible behaviour. Do not mix a broad clean-up with unrelated feature work when the changes can be separated.
Common Mistakes
Treating Every Smell as an Emergency
A smell is a signal, not an automatic defect. A long configuration method may be perfectly readable. A switch used once may be clearer than several classes. Evaluate the cost of change before choosing a refactoring.
Extracting Methods Without Moving Responsibility
Turning one long method into twenty private methods can improve navigation while leaving the god class intact. Use extraction to discover responsibilities, then move cohesive behaviour to focused components.
Replacing Primitives with Empty Wrappers
A class that only stores a string without validation or behaviour may add little value. A domain type should clarify meaning, enforce constraints, or own useful operations.
Creating Too Many Layers
Excessive layering can itself cause Shotgun Surgery. When one field requires matching edits in request objects, domain objects, persistence objects, mappers, and response objects, verify that each layer protects a real boundary.
Removing All Delegation
Deleting every forwarding method can create message chains and expose internal object graphs. Preserve methods that provide a stable business-facing operation.
Splitting by Technical Convenience Instead of Domain Meaning
A class named CommonCalculations may collect unrelated logic again. Prefer names tied to a business responsibility, such as DiscountPolicy, DeliverySelection, or OrderNotifier.
Checklist
Use this checklist when reviewing a Java service:
- Does one business rule appear in several files?
- Does a small requirement require many unrelated edits?
- Does one class change for several different reasons?
- Does a method mix calculation, persistence, formatting, and communication?
- Are domain concepts represented as generic strings or numbers?
- Are the same switches repeated in multiple places?
- Does a method mostly read data from another object?
- Does a class require many unrelated collaborators?
- Are callers navigating long chains of nested objects?
- Are forwarding methods adding meaning or only indirection?
- Can a new variant be added without editing stable orchestration code?
- Is there enough test coverage to preserve behaviour during structural changes?
Several "yes" answers do not demand a rewrite. They identify where focused refactoring can reduce the cost and risk of future changes.
Conclusion
Code smells matter because they reveal where a codebase resists change. Duplicated knowledge, long methods, primitive obsession, feature envy, divergent responsibilities, Shotgun Surgery, and god objects are different symptoms of the same broader problem: behaviour and ownership are not aligned.
The most effective refactoring begins with a painful business change. Trace where that change spreads, identify the knowledge being repeated, and move it toward one clear owner. Use domain types to make invalid usage harder, use focused components to separate reasons for change, and keep the checkout service as an understandable coordinator.
The result is not merely cleaner code. It is a system in which the next pricing rule can be implemented where a developer expects to find it, without sending changes through the entire application.