Decomposing the God Class
Decomposing the God Class
The Smell
The logistics platform’s ShipmentService is the file that every developer dreads opening. The scroll bar in the IDE is a sliver, indicating a very long file. Finding a method requires Ctrl+F because scrolling is impractical. The class has 23 injected dependencies, which means its constructor has 23 parameters (or the equivalent with field injection, which hides the problem without solving it). Pull requests that modify ShipmentService have a median review time of 90 minutes. Pull requests that modify other service classes have a median review time of 25 minutes.
The class is a God class. It knows about everything. It does everything. It is the first class every new feature touches and the last class any developer volunteers to modify.
The Cognitive Cost
A reader opening ShipmentService to understand rate calculation must scroll past:
- Status transition logic (lines 1 through 380)
- Carrier assignment logic (lines 381 through 620)
- Label generation logic (lines 621 through 890)
- Rate calculation logic (lines 891 through 1150), the part they actually need
- Tracking event processing (lines 1151 through 1600)
- Billing event generation (lines 1601 through 1950)
- Customer notification logic (lines 1951 through 2200)
- Reporting queries (lines 2201 through 2500)
- Audit logging (lines 2501 through 2700)
- Batch processing (lines 2701 through 3000)
- Migration utilities (lines 3001 through 3400)
The reader needs 260 lines. They must navigate past 3,140 lines they do not need. Each method they pass is a potential distraction. Each dependency they see in the imports is a question: “Does rate calculation depend on this?” The class forces the reader to distinguish relevant from irrelevant across 3,400 lines. That filtering is pure extraneous cognitive load.
The 23 dependencies create a different burden. When the reader finds the rate calculation method, they must determine which of the 23 injected objects it uses. The constructor (or the field list) does not group dependencies by responsibility. shipmentRepository, carrierRepository, rateRepository, warehouseRepository, customerRepository, billingService, notificationService, labelService, auditService, trackingService, and thirteen more are listed in no particular order.
The Before
// HARD TO READ: 3,400 lines, 47 public methods, 23 dependencies.
// Rate calculation logic is buried at line 891.
// A reader must scroll past 890 lines of unrelated code to find it.
@Service
public class ShipmentService {
private final ShipmentRepository shipmentRepo;
private final CarrierRepository carrierRepo;
private final RateRepository rateRepo;
private final WarehouseRepository warehouseRepo;
private final CustomerRepository customerRepo;
private final BillingService billingService;
private final NotificationService notificationService;
private final LabelService labelService;
private final AuditService auditService;
private final TrackingService trackingService;
private final TaxService taxService;
private final CurrencyService currencyService;
private final WebhookService webhookService;
private final EdiService ediService;
private final EmailService emailService;
private final SmsService smsService;
private final ReportingService reportingService;
private final BatchService batchService;
private final MigrationService migrationService;
private final CacheService cacheService;
private final ValidationService validationService;
private final AddressService addressService;
private final InventoryService inventoryService;
// 47 public methods spanning 11 different responsibilities...
// RATE CALCULATION (lines 891-1150)
public ShipmentRate calculateRate(Long shipmentId, Long carrierId) {
Shipment shipment = shipmentRepo.findById(shipmentId).orElseThrow();
Carrier carrier = carrierRepo.findById(carrierId).orElseThrow();
// 260 lines of rate calculation logic
// Uses: shipmentRepo, carrierRepo, rateRepo, taxService, currencyService
// Does not use: the other 18 dependencies
}
// LABEL GENERATION (lines 621-890)
public byte[] generateLabel(Long shipmentId, LabelFormat format) { /* ... */ }
// STATUS TRACKING (lines 1-380)
public void updateStatus(Long shipmentId, ShipmentStatus status) { /* ... */ }
// ... 44 more public methods
}
The Fix
The decomposition uses responsibility clustering. Group the 47 methods by which dependencies they use. Methods that share the same dependencies belong together because they operate on the same concepts.
Cluster analysis of ShipmentService reveals five clusters:
| Cluster | Methods | Shared Dependencies | Lines |
|---|---|---|---|
| Rate calculation | 6 | carrierRepo, rateRepo, taxService, currencyService | 260 |
| Tracking | 8 | trackingService, webhookService, ediService | 450 |
| Labeling | 4 | labelService, carrierRepo | 270 |
| Billing events | 5 | billingService, taxService, currencyService | 350 |
| Notifications | 7 | notificationService, emailService, smsService, webhookService | 250 |
Each cluster becomes its own class:
// READABLE: Rate calculation is a focused class with 4 dependencies.
// A reader opens this class and sees only rate-related code.
@Service
public class ShipmentRateCalculator {
private final ShipmentRepository shipments;
private final CarrierRateRepository carrierRates;
private final TaxCalculator taxes;
private final CurrencyConverter currencies;
public ShippingQuote calculateQuote(ShipmentId shipmentId, CarrierId carrierId) {
Shipment shipment = shipments.findOrFail(shipmentId);
CarrierRates rates = carrierRates.findForCarrier(carrierId);
BigDecimal baseRate = rates.calculateBase(shipment.route(), shipment.cargo());
BigDecimal taxedRate = taxes.applyTax(baseRate, shipment.route().destination());
Money rate = currencies.convert(taxedRate, shipment.billingCurrency());
return new ShippingQuote(shipmentId, carrierId, rate);
}
public ShippingQuote recalculate(ShipmentId shipmentId) {
Shipment shipment = shipments.findOrFail(shipmentId);
return calculateQuote(shipmentId, shipment.carrierId());
}
}
// READABLE: Tracking is a focused class with 3 dependencies.
@Service
public class ShipmentTracker {
private final ShipmentRepository shipments;
private final TrackingEventPublisher events;
private final CarrierTrackingGateway carrierTracking;
public StatusTransition updateStatus(ShipmentId shipmentId,
ShipmentStatus newStatus) {
Shipment shipment = shipments.findOrFail(shipmentId);
StatusTransition transition = shipment.transitionTo(newStatus);
shipments.save(shipment);
events.publish(transition);
return transition;
}
public TrackingHistory getHistory(ShipmentId shipmentId) {
return carrierTracking.getHistory(shipmentId);
}
}
Each extracted class has 3 to 5 dependencies, down from 23. Each class has 4 to 8 methods, down from 47. Each class fits on a single screen. A developer working on rate calculation opens ShipmentRateCalculator and sees nothing about labeling, tracking, billing, or notifications. The cognitive load reduction is proportional: instead of filtering 3,400 lines to find 260 relevant ones, the developer sees only the 260 relevant lines.
The Rule
If a class has more than 7 public methods or more than 5 injected dependencies, cluster its methods by shared dependency usage and extract each cluster into its own class. The dependency overlap determines the clusters, not your intuition about “responsibilities.” Methods that share the same repositories and services operate on the same concepts and belong together. Methods that use different dependencies operate on different concepts and should be in different classes. Count the dependencies. If the constructor has more parameters than your working memory can hold (more than 4), the class is too large.