The Method That Requires a Meeting
The Method That Requires a Meeting
The Smell
The logistics platform has a method in ShipmentService called processShipmentUpdate. When a new developer asks what it does, the answer takes twenty minutes and involves drawing on a whiteboard. The method is 180 lines long. It handles status transitions, carrier notification, inventory adjustment, billing event generation, and customer notification. A developer cannot fix the carrier notification bug without understanding the billing event generation, because they share mutable state through a local variable called context.
Every time this method is modified, the pull request review takes an hour. Reviewers ask the same question: “Does this change affect the billing path?” The answer is always “I think not, but I’m not sure.” That uncertainty is the cognitive load made visible.
The Cognitive Cost
To make any change to processShipmentUpdate, a developer must hold in working memory:
- The six possible shipment status values and which transitions are valid
- The meaning of the
contextmap, which accumulates data across all five subsystem interactions - Which of the three carrier notification strategies applies based on carrier type
- Whether the inventory adjustment has already been committed or is still pending
- The billing event format, which differs for domestic and international shipments
- The customer notification rules, which depend on the customer’s notification preferences stored in a separate table
Six independent concepts, each from a different subdomain. Working memory holds four. The developer is guaranteed to forget at least two, and they will not know which two until the bug report arrives.
The Before
// HARD TO READ: This method requires knowledge of five subdomains to make any change.
// SonarQube cognitive complexity: 34. The threshold that predicts review difficulty is 15.
public void processShipmentUpdate(Long shipmentId, ShipmentStatus newStatus,
boolean notifyCustomer, String updatedBy) {
Shipment shipment = shipmentRepository.findById(shipmentId)
.orElseThrow(() -> new ShipmentNotFoundException(shipmentId));
// Reader must memorize: which status transitions are valid?
ShipmentStatus oldStatus = shipment.getStatus();
if (oldStatus == ShipmentStatus.DELIVERED || oldStatus == ShipmentStatus.CANCELLED) {
throw new InvalidStatusTransitionException(oldStatus, newStatus);
}
if (oldStatus == ShipmentStatus.IN_TRANSIT && newStatus == ShipmentStatus.PENDING) {
throw new InvalidStatusTransitionException(oldStatus, newStatus);
}
Map<String, Object> context = new HashMap<>(); // Reader must track what goes in here
context.put("oldStatus", oldStatus);
context.put("newStatus", newStatus);
context.put("timestamp", Instant.now());
shipment.setStatus(newStatus);
shipment.setUpdatedBy(updatedBy);
shipmentRepository.save(shipment);
// Reader must now understand carrier notification logic
Carrier carrier = carrierRepository.findById(shipment.getCarrierId())
.orElseThrow(() -> new CarrierNotFoundException(shipment.getCarrierId()));
if (carrier.supportsStatusCallback()) {
if (carrier.getCallbackType() == CallbackType.WEBHOOK) {
webhookService.sendStatusUpdate(carrier, shipment);
} else if (carrier.getCallbackType() == CallbackType.EDI) {
ediService.sendStatusUpdate(carrier, shipment);
} else {
emailService.sendCarrierNotification(carrier, shipment);
}
}
context.put("carrierNotified", carrier.supportsStatusCallback());
// Reader must now switch to inventory logic
if (newStatus == ShipmentStatus.PICKED_UP) {
List<ShipmentItem> items = shipmentItemRepository.findByShipmentId(shipmentId);
for (ShipmentItem item : items) {
WarehouseSlot slot = warehouseRepository.findSlot(item.getWarehouseId(),
item.getBinLocation());
slot.setQuantity(slot.getQuantity() - item.getQuantity());
warehouseRepository.save(slot);
}
context.put("inventoryAdjusted", true);
}
// Reader must now switch to billing logic
if (newStatus == ShipmentStatus.DELIVERED) {
BillingEvent event = new BillingEvent();
event.setShipmentId(shipmentId);
event.setEventType("DELIVERY_COMPLETE");
event.setAmount(shipment.getCalculatedRate());
if (shipment.isInternational()) {
event.setTaxRate(taxService.getInternationalRate(shipment.getDestinationCountry()));
event.setCurrency(currencyService.getLocalCurrency(
shipment.getDestinationCountry()));
} else {
event.setTaxRate(taxService.getDomesticRate());
event.setCurrency("USD");
}
billingEventRepository.save(event);
context.put("billed", true);
}
// Reader must now switch to notification logic
if (notifyCustomer) {
Customer customer = customerRepository.findById(shipment.getCustomerId())
.orElseThrow(() -> new CustomerNotFoundException(shipment.getCustomerId()));
if (customer.getNotificationPreference() == NotificationPreference.EMAIL) {
emailService.sendStatusNotification(customer, shipment, context);
} else if (customer.getNotificationPreference() == NotificationPreference.SMS) {
smsService.sendStatusNotification(customer, shipment);
} else if (customer.getNotificationPreference() == NotificationPreference.WEBHOOK) {
webhookService.sendCustomerUpdate(customer, shipment, context);
}
}
auditLogService.log("SHIPMENT_STATUS_CHANGE", shipmentId, context);
}
The Fix
// READABLE: Each concern is handled by a dedicated component.
// The orchestrating method reads like a checklist. Cognitive complexity: 4.
// A developer fixing carrier notification reads only CarrierNotifier.
public void updateShipmentStatus(Long shipmentId, StatusChangeRequest request) {
Shipment shipment = shipments.findOrFail(shipmentId);
StatusTransition transition = shipment.transitionTo(request.newStatus());
// transition validates itself; invalid transitions throw before we reach this line
shipments.save(shipment);
carrierNotifier.notifyStatusChange(shipment, transition);
inventoryAdjuster.adjustForTransition(shipment, transition);
billingEvents.recordIfDelivered(shipment, transition);
customerNotifier.notifyIfRequested(shipment, transition, request.notifyCustomer());
auditLog.record(transition);
}
// READABLE: The status transition validates itself.
// A reader does not need to memorize which transitions are valid.
public sealed interface StatusTransition permits
PickedUp, InTransit, InCustoms, OutForDelivery, Delivered, Cancelled {
ShipmentStatus from();
ShipmentStatus to();
Instant occurredAt();
static StatusTransition create(ShipmentStatus from, ShipmentStatus to) {
return switch (to) {
case PICKED_UP -> new PickedUp(from, to, Instant.now());
case IN_TRANSIT -> new InTransit(from, to, Instant.now());
case IN_CUSTOMS -> new InCustoms(from, to, Instant.now());
case OUT_FOR_DELIVERY -> new OutForDelivery(from, to, Instant.now());
case DELIVERED -> new Delivered(from, to, Instant.now());
case CANCELLED -> new Cancelled(from, to, Instant.now());
case PENDING -> throw new InvalidStatusTransitionException(from, to);
};
}
}
// READABLE: Carrier notification is self-contained.
// A reader working on carrier bugs never needs to open billing or inventory code.
public class CarrierNotifier {
private final CarrierRepository carriers;
private final WebhookService webhooks;
private final EdiService edi;
private final EmailService email;
public void notifyStatusChange(Shipment shipment, StatusTransition transition) {
Carrier carrier = carriers.findOrFail(shipment.carrierId());
if (!carrier.supportsStatusCallback()) {
return;
}
switch (carrier.callbackType()) {
case WEBHOOK -> webhooks.sendStatusUpdate(carrier, shipment);
case EDI -> edi.sendStatusUpdate(carrier, shipment);
case EMAIL -> email.sendCarrierNotification(carrier, shipment);
}
}
}
The original method required a reader to hold six concepts from five subdomains. The refactored version requires a reader to hold one concept: a shipment status update triggers a sequence of domain actions. Each action is in its own class. A developer fixing the carrier notification reads CarrierNotifier and nothing else. The context map, which was the shared mutable state coupling all five concerns, no longer exists.
The Rule
If understanding a method requires knowledge of more than two subdomains, split it into an orchestrator that delegates to single-responsibility components. The orchestrator should read like a checklist. Each component should be understandable without opening any other component. If you cannot explain what a method does without drawing on a whiteboard, the method is doing too much.