The class works and the tests pass. The problem is that every change touches it.
Before: OrderService, 1,400 lines, 31 public methods
validates the cart -> business rules
applies discount codes -> pricing rules
calls the payment gateway -> external integration
writes rows to three tables -> persistence
renders the receipt email -> presentation
publishes an audit event -> messaging
formats currency for the UI -> formatting
Reasons this class changes: a tax rate, a gateway API version, a schema
migration, a copy edit in the email, a new audit field. Five unrelated teams,
one file, one merge conflict.
The useful test is not size, it is the number of reasons to change. Every
distinct actor who can force an edit is a separate responsibility, and a class
with five of them will be edited five times as often as anything else in the
codebase, which is where the merge conflicts and the accidental regressions come
from.
The refactoring is mechanical once you have that list. PricingPolicy owns
discounts and tax. PaymentGateway is an interface with one adapter per
provider. OrderRepository owns persistence. ReceiptMailer owns the email.
OrderService keeps only the orchestration — the sequence of steps, the
transaction boundary, and what happens when a step fails.
The detail candidates miss is what the orchestrator is allowed to keep. Splitting
a god class into six anaemic data holders plus a service that still does all the
thinking has moved the problem, not solved it: the behaviour belongs with the
data it constrains, so the discount calculation goes into PricingPolicy with
the rates, not into a static helper the service calls.
The measurable win is testability. Pricing rules become a pure unit test with no
gateway, no database and no mail server, and that is usually the moment the team
discovers which of the rules nobody could previously state.