ShipmentService is 800 lines long. Not because shipments are hard, but because it became the place every piece of carrier logic went to die. To answer one simple question, “how does FedEx behave differently from UPS?”, we have to read through four service classes and stitch the answer back together ourselves.
The size is the symptom. The behavior never found a proper home, so it pooled in a service that has no reason to say no to anything, and it kept piling up, one branch at a time, until nobody could hold the whole thing in their head.
The Symptom: One Carrier, Four Files
Pick a single carrier and try to find it. FedEx isn’t in one place. Its rate rules live in RateService, its label format hides in LabelService, its tracking-poll schedule in TrackingService, and its routing in ShipmentService. Four files, four slices, and none of them owns the carrier.
Now flip it around. Each service owns a slice of every carrier. RateService knows FedEx’s rates, UPS’s rates, and DHL’s rates1. LabelService knows all three label formats. The carriers are smeared sideways across the layer, so a service ends up knowing a little about everything and a lot about nothing2.
The consequence is shotgun surgery. Onboard a new carrier and we touch four files. Change how one carrier prices a surcharge and we open RateService, find the right branch among all the others, and pray we didn’t miss a sibling branch in ShipmentService that assumed the old behavior3. That’s the symptom. The next section is the cause.
Why It Happened
Two causes build on top of each other, and neither is laziness.
The first is the anemic domain model. We’ve covered this one in depth, so just a quick recap: when data lives in objects and behavior lives in services, the domain objects have nowhere to hold a rule, so the rule pools in a service by default. (If that diagnosis is new, start with The Anemic Domain Model Trap.) With a hollow domain, the behavior had to land somewhere, and the services were the only rooms with the lights on.
The second cause is the name itself. Service is a framework word, not a domain word. It names where code sits, not what it owns. Ask “what does ShipmentService do?” and the honest answer is “shipment stuff”, which is too open-ended. There’s no question a new method could fail, no edge it could cross, so nothing ever gets turned away. The scope creep isn’t an accident. It’s the suffix working as designed, and we’ll come back to exactly why later.
The Fix That Isn’t
The usual fix is “split the big service into smaller services”. Split ShipmentService into ShipmentRoutingService, ShipmentRateService, and a couple more, and the 800-line file becomes five files of a couple hundred lines each. It feels like progress. The diff is huge, the line counts look healthier, and the pull request gets approved.
But nothing structural changed. The smaller services are still anemic: the behavior is still off the domain objects that own the data. They’re still suffixed: still framework words with no boundary, so each one will start collecting again the moment a new rule needs a home. And they’re still dumping grounds, just smaller ones. We traded one 800-line bucket for five 160-line buckets and renamed the problem.
The unit of the fix isn’t file size. It’s behavior. Splitting by size moves lines around, while moving behavior changes who owns what. Until the ownership moves, we’re rearranging the same logic into more, smaller piles.
Carriers Want to Be Strategies
The principle here is the one we already know: behavior belongs to the object that owns the data, not in a service. We covered that in The Anemic Domain Model Trap, and drew the line between domain rules and orchestration in Where Does Application Logic Go?. We won’t re-run either. The new question is different: for logic that’s keyed by a type rather than owned by a single entity, where is home? A shipment owns its weight and destination. But “how FedEx prices a surcharge” isn’t owned by any one shipment. It’s owned by the carrier.
Look at what the services are actually doing with carriers. RateService decides how to price by asking which carrier it’s holding. LabelService formats by asking the same question. TrackingService schedules its polls the same way. Spell out what that looks like and the structure is unmistakable:
public Money calculateRate(Shipment shipment) {
if (shipment.carrier() == FEDEX) {
// FedEx pricing rules
} else if (shipment.carrier() == UPS) {
// UPS pricing rules
} else if (shipment.carrier() == DHL) {
// DHL pricing rules
}
// ...
}That’s a type-code switch in disguise. It doesn’t always have a switch keyword. Sometimes it’s an if/else if ladder. Other times a map lookup. But the shape is the same: branch on a type code, then do the type-specific thing. And we already know where that shape goes.
The switch-case series walked the whole path: a type code becomes a class, the variants become subclasses, and the behavior moves behind a common interface as State or Strategy4. Carriers are just that refactoring applied one level higher. The carrier isn’t a field to branch on. It’s the strategy.
So each carrier becomes a CarrierPolicy behind one interface:
public interface CarrierPolicy {
Money rate(Shipment shipment);
Label label(Shipment shipment);
Duration trackingPollInterval();
}
public final class FedExPolicy implements CarrierPolicy {
@Override
public Money rate(Shipment shipment) { /* FedEx pricing */ }
@Override
public Label label(Shipment shipment) { /* FedEx label format */ }
@Override
public Duration trackingPollInterval() { /* FedEx schedule */ }
}
public final class UpsPolicy implements CarrierPolicy {
@Override
public Money rate(Shipment shipment) { /* UPS pricing */ }
@Override
public Label label(Shipment shipment) { /* UPS label format */ }
@Override
public Duration trackingPollInterval() { /* UPS schedule */ }
}Now return to the symptom. FedEx used to be distributed across four files. Here, everything FedEx-related is in one class. Its rate, its label, its tracking schedule live together because they belong together: they’re all “how FedEx behaves”. Onboard a new carrier and we write one new class instead of touching four.
That is the Open/Closed Principle doing its job: open to a new carrier by extension, closed to edits in code that already works. The shotgun surgery disappears with it. The if (carrier == ...) ladders disappear, because the type no longer gets asked, it gets dispatched. The services stop knowing about carriers at all. They hold a CarrierPolicy and call it5.
Name the Line We’re Holding
Earlier we said the suffix works as designed. Here’s why.
Where Does Application Logic Go? gave us the alternatives: domain names like CarrierPolicy instead of the Service suffix. This is the mechanism, the reason those names actually hold the line where Service lets it collapse.
A domain name carries a definition, and a definition has an edge. RateCalculator means “computes a rate”. The name tells us what belongs and, just as loudly, what doesn’t. The moment someone tries to add a method that schedules a tracking poll, it visibly doesn’t fit. It isn’t about computing a rate, so it gets pushed out to wherever it belongs. Nobody has to enforce that. The name does the policing.
Service carries no definition. It names a layer, not a responsibility. ShipmentService means “shipment stuff”, and there’s no method we could write that fails to be shipment stuff. Everything shipment-looking qualifies, so nothing ever gets rejected. That’s why the dumping ground never stops filling: there’s no edge to cross, so there’s nothing to violate. The 800 lines aren’t a discipline failure. They’re the predictable result of a name with no boundary6.
This is what a domain name buys us that a suffix can’t. CarrierPolicy won’t quietly grow a method about labels for a shipment that has no carrier, because that method has no business on a carrier policy. The name is doing structural work. It’s not decoration, it’s a fence.
Where to Start
A codebase with this problem usually has more than one bloated service. Extracting all of them at once is how a refactor turns into a six-month rewrite that never ships. So we don’t start everywhere. We start where it hurts most.
The service that changes most often is the one paying the highest tax for being a dumping ground, and the one where an extraction returns the most. Churn is the signal. Every time somebody touched it, they had to read around the branches to find the one that mattered. Git already tracks this for us, so we let it tell us which file to open. (The exact command is in the “Try This Week” section below.)
Once we’ve found it, the extraction map is already drawn for us. We open the file and highlight every block that branches on a carrier, a type, or a variant. Each of those if (carrier == ...) ladders, each cluster of type-specific logic, is a CarrierPolicy or a RateCalculator waiting to be pulled out. The boundaries aren’t hidden. They’re exactly the lines where the code asks “which kind is this?” We cut along them, one variant at a time, and the service shrinks toward the orchestration it should have been doing all along.
The Takeaway
A service class doesn’t know too much because it grew too big. It knows too much because the behavior never went home, and a name with no edge will never send it there.
TLDR
Service classes that contain business logic for multiple domain concerns are a symptom of an anemic domain model.
The fix isn’t “split the service into smaller services”. It’s “move behavior to the domain objects that own it”.
For carrier-specific logic: the Strategy pattern encapsulates each carrier’s rules behind a common interface.
Start with the most-changed service class (git log shows which one). That’s where the extraction has the highest ROI.
Try This Week
Run this for each of your service classes to calculate how many times they were touched in a commit in the last 6 months:
git log --format='%H' --since="6 months ago" -- path/to/your/services/ | wc -lThe one with the most commits is your highest-pain service. Open it and highlight every block that belongs to a specific carrier, type, or variant. That’s your extraction map. You don’t have to extract anything this week. Just look at how many seams you find, and notice that not one of them was the thing the service was named after.
Media attributions:
Cover image by the author
Hyrum’s law, shipping edition: with enough integrations, every observable quirk of how a carrier behaves is something a downstream system now depends on. Which is why carrier logic never shrinks. It only moves.
This is divergent change: one class edited for many unrelated reasons (every carrier’s pricing rules), which is a Single Responsibility Principle violation by another name.
I once spent an hour reading a 200-line method to add a feature, only to find the only branch that called it was already dead. It was all wasted time. The code did nothing. A type-code ladder is where dead branches go to hide.
State and Strategy have the same structure: an interface with swappable implementations. They differ in intent. State changes with an object’s own lifecycle over time, Strategy is chosen by the caller and stays fixed for the operation. A carrier is a Strategy: it is decided per shipment, not a phase the shipment moves through.
Something still has to pick the right CarrierPolicy for a shipment, so did we just move the switch into a factory? The difference is that the choice now lives in ONE place, a registry keyed by carrier, dependency injection, or generated wiring, instead of a fresh if (carrier == ...) in every service. One place, not four. The Feature Flags post built exactly this kind of generated dispatch.
My earlier posts used the XService naming, and so does much of the example code here, to stay close to the conventions most Spring codebases already use. That’s deliberate: it’s easier to recognize the smell in code that looks like ours. The argument is precisely that the convention is the problem.

