Change Preventers — Practice Tasks¶
12 hands-on exercises across the three Change Preventers, with full solutions.
Task 1 — Divergent Change (Java)¶
Problem: OrderManager is changed for many reasons. Split it.
class OrderManager {
public void placeOrder(Order o) { ... }
public void cancelOrder(Order o) { ... }
public void refundOrder(Order o, BigDecimal amount) { ... }
public void shipOrder(Order o) { ... }
public void trackOrder(Order o) { ... }
public BigDecimal applyDiscount(Order o, String coupon) { ... }
public BigDecimal calculateTax(Order o) { ... }
public BigDecimal calculateShipping(Order o) { ... }
public void sendOrderConfirmationEmail(Order o) { ... }
public void sendShippingNotification(Order o) { ... }
}
Solution:
class OrderLifecycleService {
public void place(Order o) { ... }
public void cancel(Order o) { ... }
public void refund(Order o, BigDecimal amount) { ... }
}
class FulfillmentService {
public void ship(Order o) { ... }
public TrackingInfo track(Order o) { ... }
}
class PricingService {
public BigDecimal applyDiscount(Order o, String coupon) { ... }
public BigDecimal calculateTax(Order o) { ... }
public BigDecimal calculateShipping(Order o) { ... }
}
class OrderNotificationService {
public void sendOrderConfirmation(Order o) { ... }
public void sendShippingNotification(Order o) { ... }
}
Three categories of changes (lifecycle, pricing, notification) now have homes. Each service has 3-4 methods.
Task 2 — Shotgun Surgery (Java)¶
Problem: Adding a field requires editing 4 layers. Reduce the scatter.
// Domain
class Customer {
private String name;
private String email;
}
// DTO
class CustomerDto {
public String name;
public String email;
}
// JPA Entity
@Entity
class CustomerEntity {
@Id @Column public String id;
@Column public String name;
@Column public String email;
}
// Mapper
class CustomerMapper {
public CustomerDto toDto(Customer c) { return new CustomerDto(c.getName(), c.getEmail()); }
public Customer fromEntity(CustomerEntity e) { return new Customer(e.name, e.email); }
}
Solution (with MapStruct):
@Mapper(componentModel = "spring")
public interface CustomerMapper {
CustomerDto toDto(Customer customer);
Customer toDomain(CustomerEntity entity);
CustomerEntity toEntity(Customer customer);
}
// Domain (single source of truth — could be a record)
public record Customer(String name, String email) {}
@Entity
class CustomerEntity {
@Id String id;
String name;
String email;
}
class CustomerDto {
public String name;
public String email;
}
Adding "country" requires editing Customer, CustomerEntity, CustomerDto — but the mapper is regenerated. With more tooling (e.g., a Java-first GraphQL framework), the GraphQL type is also derived.
For maximum scatter reduction: declare Customer once as a Pydantic-style "schema-first" type and generate the rest. In Java, this is harder than in Python; in TypeScript it's almost free with Zod.
Task 3 — Parallel Inheritance Hierarchies (Java)¶
Problem: Eliminate the parallel hierarchy by moving methods.
abstract class Vehicle {
public abstract double weight();
}
class Car extends Vehicle { public double weight() { return 1500; } }
class Truck extends Vehicle { public double weight() { return 5000; } }
abstract class VehicleTaxCalculator {
public abstract BigDecimal calculate(Vehicle v);
}
class CarTaxCalculator extends VehicleTaxCalculator { ... }
class TruckTaxCalculator extends VehicleTaxCalculator { ... }
Solution:
abstract class Vehicle {
public abstract double weight();
public abstract BigDecimal calculateTax(); // moved here
}
class Car extends Vehicle {
public double weight() { return 1500; }
public BigDecimal calculateTax() {
return new BigDecimal("150.00"); // car tax logic
}
}
class Truck extends Vehicle {
public double weight() { return 5000; }
public BigDecimal calculateTax() {
return new BigDecimal("500.00"); // truck tax logic
}
}
// VehicleTaxCalculator hierarchy is gone.
// Adding Bicycle? Just one new class.
Task 4 — Extract Class for Divergent Change (Python)¶
Problem: User class has too many responsibilities.
class User:
def __init__(self, name, email, password_hash):
self.name = name
self.email = email
self.password_hash = password_hash
self.preferences = {}
self.subscription = None
# auth
def verify_password(self, password): ...
def reset_password(self, new_password): ...
def enable_mfa(self): ...
# profile
def update_email(self, email): ...
def update_name(self, name): ...
# preferences
def set_preference(self, key, value): ...
def get_preference(self, key): ...
# subscription
def upgrade(self, plan): ...
def cancel_subscription(self): ...
def billing_status(self): ...
Solution:
class UserCredentials:
def __init__(self, password_hash):
self.password_hash = password_hash
self.mfa_enabled = False
def verify_password(self, password): ...
def reset_password(self, new_password): ...
def enable_mfa(self): self.mfa_enabled = True
class UserProfile:
def __init__(self, name, email):
self.name = name
self.email = email
def update_email(self, email): self.email = email
def update_name(self, name): self.name = name
class UserPreferences:
def __init__(self):
self.values = {}
def set(self, key, value): self.values[key] = value
def get(self, key): return self.values.get(key)
class UserSubscription:
def __init__(self):
self.plan = None
def upgrade(self, plan): self.plan = plan
def cancel(self): self.plan = None
def status(self): return self.plan or "free"
class User:
def __init__(self, name, email, password_hash):
self.profile = UserProfile(name, email)
self.credentials = UserCredentials(password_hash)
self.preferences = UserPreferences()
self.subscription = UserSubscription()
Task 5 — Inline Class for Shotgun Surgery (Java)¶
Problem: OrderId and OrderIdGenerator are split unnecessarily — every change to ID format requires editing both.
class OrderId {
private final String value;
public OrderId(String value) { this.value = value; }
public String value() { return value; }
}
class OrderIdGenerator {
public OrderId generate() {
return new OrderId("ORD-" + UUID.randomUUID());
}
}
Solution:
final class OrderId {
private final String value;
public OrderId(String value) { this.value = value; }
public String value() { return value; }
public static OrderId generate() {
return new OrderId("ORD-" + UUID.randomUUID());
}
}
Static factory on the value type. One file owns ID generation and representation. ID format change = one file edit.
Task 6 — Bridge instead of Parallel Hierarchy (Java)¶
Problem: Two parallel hierarchies represent two real axes — apply Bridge instead of Move.
// Hierarchy 1: rendering
abstract class Renderer { abstract void render(Shape s); }
class CanvasRenderer extends Renderer { ... }
class SvgRenderer extends Renderer { ... }
// Hierarchy 2: shapes — currently each is rendered specifically
class CircleOnCanvas extends Renderer { ... } // coupled
class CircleAsSvg extends Renderer { ... } // coupled
class SquareOnCanvas extends Renderer { ... }
class SquareAsSvg extends Renderer { ... }
This is a 2D matrix: shapes × renderers = 4 (then 6, then 9...) classes.
Solution (Bridge pattern):
abstract class Shape {
protected final Renderer renderer;
Shape(Renderer r) { this.renderer = r; }
public abstract void draw();
}
interface Renderer {
void drawCircle(double r);
void drawSquare(double side);
}
class Circle extends Shape {
private final double radius;
Circle(Renderer r, double radius) { super(r); this.radius = radius; }
public void draw() { renderer.drawCircle(radius); }
}
class Square extends Shape {
private final double side;
Square(Renderer r, double side) { super(r); this.side = side; }
public void draw() { renderer.drawSquare(side); }
}
class CanvasRenderer implements Renderer {
public void drawCircle(double r) { /* canvas-specific */ }
public void drawSquare(double s) { /* canvas-specific */ }
}
class SvgRenderer implements Renderer {
public void drawCircle(double r) { /* svg-specific */ }
public void drawSquare(double s) { /* svg-specific */ }
}
Now adding Triangle: one new Shape, plus drawTriangle method on each renderer (compiler enforces). Adding PdfRenderer: one new class, no shape changes.
Task 7 — Move Method to consolidate scatter (Python)¶
Problem: Calculating an order's total is scattered across helper functions in different modules.
# In order_helpers.py
def calculate_subtotal(order): ...
# In tax_helpers.py
def calculate_tax(order): ...
# In shipping_helpers.py
def calculate_shipping(order): ...
# In OrderService
def total(self, order):
return calculate_subtotal(order) + calculate_tax(order) + calculate_shipping(order)
Solution: move calculation methods onto Order.
@dataclass
class Order:
items: list
customer: 'Customer'
def subtotal(self) -> Decimal: ...
def tax(self) -> Decimal: ...
def shipping(self) -> Decimal: ...
def total(self) -> Decimal:
return self.subtotal() + self.tax() + self.shipping()
Order knows everything about itself. Adding "discount" = one new method on Order, called from total(). No scatter.
Task 8 — Identify the smell (Java, open-ended)¶
Problem: Identify the Change Preventer(s) and describe the cure.
// File: src/main/java/.../UserController.java
class UserController {
@PostMapping("/api/users")
public ResponseEntity<?> create(@RequestBody UserRequest req) {
User user = new User();
user.setName(req.getName());
user.setEmail(req.getEmail());
user.setCountry(req.getCountry());
userRepo.save(user);
return ResponseEntity.ok(toResponse(user));
}
private UserResponse toResponse(User u) {
UserResponse r = new UserResponse();
r.name = u.getName();
r.email = u.getEmail();
r.country = u.getCountry();
return r;
}
}
// File: src/main/java/.../UserResponse.java
class UserResponse {
public String name;
public String email;
public String country;
}
// File: src/main/java/.../UserRequest.java
class UserRequest {
private String name;
private String email;
private String country;
// getters/setters...
}
// File: src/main/java/.../User.java
class User {
private Long id;
private String name;
private String email;
private String country;
// getters/setters...
}
// File: src/main/java/.../UserDto.java
class UserDto {
public String name;
public String email;
public String country;
}
// File: src/main/java/.../UserConverter.java
class UserConverter {
public UserDto toDto(User u) { ... }
}
Solution:
| Smell | Where |
|---|---|
| Shotgun Surgery | Adding "phone" requires editing 6 files |
| Alternative Classes (related) | UserRequest, UserResponse, UserDto all represent essentially the same data with different field naming |
Cures:
- MapStruct for the mapping boilerplate.
- Consolidate
UserRequest,UserResponse,UserDto— likely one or two are unnecessary. - Use Java records for the DTOs (less boilerplate).
- Consider if
Userand the API DTO can share fields via an interface or a code-generation step.
After:
public record UserRequest(String name, String email, String country) {}
public record UserResponse(String name, String email, String country) {}
@Mapper(componentModel = "spring")
interface UserMapper {
UserResponse toResponse(User u);
User toDomain(UserRequest r);
}
Adding "phone": one field added to records + entity = 3 small edits, mapper regenerated.
Task 9 — Cross-cutting concern, not Shotgun Surgery (Java)¶
Problem: Every method in a service starts with auth check. Refactor.
class OrderService {
public void placeOrder(User user, Order o) {
if (!authService.isAuthenticated(user)) throw new UnauthorizedException();
if (!authService.canPlaceOrder(user)) throw new ForbiddenException();
// ... business logic
}
public void cancelOrder(User user, OrderId id) {
if (!authService.isAuthenticated(user)) throw new UnauthorizedException();
if (!authService.canCancelOrder(user, id)) throw new ForbiddenException();
// ... business logic
}
// ... 20 more methods, all starting the same way
}
Solution: Spring AOP aspect (cross-cutting concern, not Shotgun Surgery).
@Aspect
@Component
class AuthenticationAspect {
@Before("@annotation(RequiresAuth)")
public void checkAuth(JoinPoint pjp, RequiresAuth annotation) {
User user = SecurityContextHolder.getCurrentUser();
if (!authService.isAuthenticated(user)) throw new UnauthorizedException();
if (!authService.has(user, annotation.permission()))
throw new ForbiddenException();
}
}
@Retention(RUNTIME)
@Target(METHOD)
@interface RequiresAuth {
String permission();
}
class OrderService {
@RequiresAuth(permission = "ORDER_PLACE")
public void placeOrder(Order o) { /* business */ }
@RequiresAuth(permission = "ORDER_CANCEL")
public void cancelOrder(OrderId id) { /* business */ }
}
Auth checks are declarative. Adding new methods just requires the annotation.
Task 10 — Code generation to reduce scatter (multi-language)¶
Problem: A Customer definition lives in: - Java backend - TypeScript frontend - Python data pipeline - SQL schema
Each adds fields independently. Drift is constant.
Solution: schema-first design with Protobuf.
syntax = "proto3";
message Customer {
string id = 1;
string name = 2;
string email = 3;
string country = 4;
}
Build pipeline generates: - Customer.java (via protoc-java) - customer.ts (via protoc-ts or similar) - customer.py (via protoc-python) - SQL schema (via custom generator or via migrations referenced from proto)
Adding "phone": one line in .proto. Regenerate. All four layers stay in sync.
Task 11 — Decompose god service in Go¶
Problem:
package main
type UserService struct{ db *sql.DB }
func (s *UserService) Register(name, email string) error { ... }
func (s *UserService) Login(email, password string) (*Session, error) { ... }
func (s *UserService) Logout(sessionID string) error { ... }
func (s *UserService) UpdateEmail(userID, newEmail string) error { ... }
func (s *UserService) ChangePassword(userID, oldPassword, newPassword string) error { ... }
func (s *UserService) EnableMFA(userID string) error { ... }
func (s *UserService) GetEngagementScore(userID string) (float64, error) { ... }
func (s *UserService) RecommendFriends(userID string) ([]string, error) { ... }
func (s *UserService) ExportGDPR(userID string) ([]byte, error) { ... }
func (s *UserService) DeleteForGDPR(userID string) error { ... }
Solution:
package user
type IdentityService struct{ db *sql.DB }
func (s *IdentityService) Register(name, email string) error { ... }
func (s *IdentityService) UpdateEmail(userID, newEmail string) error { ... }
type AuthService struct{ db *sql.DB }
func (s *AuthService) Login(email, password string) (*Session, error) { ... }
func (s *AuthService) Logout(sessionID string) error { ... }
func (s *AuthService) ChangePassword(userID, oldPassword, newPassword string) error { ... }
func (s *AuthService) EnableMFA(userID string) error { ... }
type AnalyticsService struct{ db *sql.DB }
func (s *AnalyticsService) GetEngagementScore(userID string) (float64, error) { ... }
func (s *AnalyticsService) RecommendFriends(userID string) ([]string, error) { ... }
type GDPRService struct{ db *sql.DB }
func (s *GDPRService) Export(userID string) ([]byte, error) { ... }
func (s *GDPRService) Delete(userID string) error { ... }
Four packages or four files in one package, each cohesive. Cure for Divergent Change at Go scale.
Task 12 — Audit a real codebase for Change Preventers¶
Problem: Apply this analysis to a project you maintain:
- List the top 5 files by recent change frequency (
git log --since='6 months' --pretty=format: --name-only | sort | uniq -c | sort -nr | head -5). - For each, read the recent commit messages. Are they about one topic, or many?
- List the top 5 file pairs by co-change (use a script or manual inspection).
- For each pair, ask: are they always changed together by necessity (cure: consolidate) or by chance (no smell)?
Solution: there's no canned answer — every codebase is different. Apply the diagnostic and decide:
- Top file with diverse commits: candidate for Extract Class.
- File pairs with high co-change: candidate for Inline Class or for code generation.
- Files appearing in top change-frequency for years without growing in size: stable, well-designed; leave alone.
Refactor priority is change-driven, not aesthetics-driven. Refactor what hurts.
Next: find-bug.md — bugs hiding in Change Preventer patterns.