June 17, 2026 in code-smells4 minutes
A intermediate-level guide to Shotgun Surgery: before-and-after java code and diagrams for a CS student.
Imagine you are tasked with adding a new order type to an existing system. Instead of making one logical change in one file, you find yourself opening Order.java, then hunting down Shipping.java to update its logic, and finally searching through Invoice.java to ensure the taxes remain correct. You finish your task, but you feel uneasy. Did you miss a fourth or fifth location?
This feeling is the hallmark of Shotgun Surgery. It occurs when a single responsibility—in this case, how an order type dictates behavior—is spread across multiple classes. When that responsibility changes, it “sprays” edits across the codebase like buckshot from a shotgun.
classDiagram
class Order {
+String type
+double amount
+isTaxable() boolean
}
class Shipping {
+calculateShipping(Order order) void
}
class Invoice {
+generateInvoice(Order order) void
}
Shipping ..> Order : dependency
Invoice ..> Order : dependency
In the diagram above, notice how Shipping and Invoice both depend on the internal details of Order. While they are separate classes, they are tightly coupled; their logic is tied to the specific values inside an Order object.
class Order {
String type; // "RETAIL" or "WHOLESALE"
double amount;
Order(String type, double amount) {
this.type = type;
this.amount = amount;
}
public boolean isTaxable() {
return !"WHOLESALE".equals(type);
}
}
class Shipping {
void calculateShipping(Order order) {
if ("RETAIL".equals(order.type)) {
System.out.println("Standard shipping cost applied.");
} else {
System.out.println("Freight shipping cost applied.");
}
}
}
class Invoice {
void generateInvoice(Order order) {
if (order.isTaxable()) {
System.out.println("Applying standard sales tax.");
} else {
System.out.println("Applying zero-rated tax rate.");
}
}
}To fix this, we perform a refactoring called Move Method. The goal is to take logic that is currently being performed by one class using another’s data and move that logic into the class that actually owns the data.
By moving the shipping and tax decision-making inside the Order class, we achieve better encapsulation. Encapsulation is the practice of bundling data with the methods that operate on that data, hiding the internal details from the outside world. When responsibility is encapsulated, a change to “how shipping works” only requires changing one method in one class.
class Order {
String type; // "RETAIL" or "WHOLESALE"
double amount;
Order(String type, double amount) {
this.type = type;
this.amount = amount;
}
public boolean isTaxable() {
return !"WHOLESALE".equals(type);
}
void applyShippingLogic() {
if ("RETAIL".equals(type)) {
System.out.println("Standard shipping cost applied.");
} else {
System.out.println("Freight shipping cost applied.");
}
}
void applyTaxLogic() {
if (isTaxable()) {
System.out.println("Applying standard sales tax.");
} else {
System.out.println("Applying zero-rated tax rate.");
}
}
}
class Shipping {
void calculateShipping(Order order) {
order.applyShippingLogic();
}
}
class Invoice {
void generateInvoice(Order order) {
order.applyTaxLogic();
}
}By applying Move Method, we have transformed the relationship from a scattered set of dependencies into a clean delegation. Now, when you need to add a new order type, you modify Order, and the other classes automatically behave correctly because they simply ask the Order what it should do.
sequenceDiagram
participant S as Shipping
participant O as Order
participant I as Invoice
S ->> O: applyShippingLogic()
O -->> S:
I ->> O: applyTaxLogic()
O -->> I:
Move Method is highly effective when you notice that a single requirement change forces you to touch many different files. This often happens because logic has “leaked” out of its natural home.
However, be wary of over-consolidation. If you move every single piece of logic into one class just to avoid touching other files, you risk creating a God Object—a massive, bloated class that tries to do everything and is impossible to maintain. The goal is to find the right home for the logic, not just to dump it all in one place.
Demonstrating the scenario where logic is consolidated inside Order to prevent Shotgun Surgery when types change.
Order retail = new Order("RETAIL", 100.0);
new Shipping().calculateShipping(retail);
new Invoice().generateInvoice(retail);