Kitchen Sink Services
How services grow without enforced boundaries — and why the result is untestable, high-blast-radius code no single person fully understands.
How It Happens
No one designs a kitchen sink service. They grow.
A service starts with a focused responsibility. Over time, related requirements arrive. Each one seems marginal — "it's only one more method," "this service already has the right dependencies," "we'll refactor later." After two years of this, the service has hundreds of lines, a dozen constructor dependencies, and handles validation, permission checking, orchestration across several domains, email coordination, encryption, and a collection of one-off operations that belong elsewhere but ended up here.
The result is a class that no single developer fully understands, that cannot be changed without reading all of it, and that cannot be tested without constructing most of the system.
The Constructor as a Diagnostic
A service's constructor is a reliable indicator of its health. A service with three or four dependencies is understandable and testable. A service with ten or twelve is a warning.
class InvitationService:
def __init__(
self,
invitation_repo: InvitationRepository,
organization_repo: OrganizationRepository,
teams_repo: TeamsRepository,
user_repo: UserRepository,
memberships_repo: MembershipsRepository,
encryption_service: EncryptionService,
email_service: EmailService,
keys_manager_service: KeysManagerService,
session: AsyncSession,
settings: Settings,
system_key: SystemKey,
organization: Optional[Organization] = None,
):Writing a test for this service requires constructing or mocking twelve dependencies. Teams respond to this cost by mocking heavily — and tests gradually diverge from production behaviour. The service becomes effectively untestable in any meaningful way.
The accumulation is not random. Each dependency arrived because a responsibility was added. Each responsibility arrived because the service was "close enough" to what was needed. The constructor tells the story of how the service grew.
Domain Misclassification
A related form: a method that clearly belongs to a different domain lives in the wrong service because it was convenient to add it where certain data was already accessible.
class OrganizationService:
# ...
# TODO: Remove or move to correct domain.
# Note: set_primary_org is fundamentally a user/membership operation,
# not an organisation operation. If kept, consider moving to UserService
# or MembershipService.
async def set_primary_org(self, user_id: UUID, org_id: UUID) -> None:
await self.organization_repo.set_primary_org(self.session, user_id, org_id)The TODO comment is significant. The developer knew the method was in the wrong place. They left it anyway — the required repository method was already written, it was one line to call it, and moving it correctly would take longer.
The TODO pattern is a decay signal. Code that acknowledges a structural problem but does not fix it will be there indefinitely. The next developer sees the TODO, decides not to address it either, and adds one more method nearby because "it's already messy here." The misclassification compounds.
The Blast Radius Problem
A kitchen sink service has a disproportionate blast radius. A bug in the service affects every operation the service performs. A change to one method requires reading the full service to assess side effects. A breaking change to one of the twelve dependencies — a signature change, a behavioural difference — must be traced through everything the service does.
In contrast, a service with a focused responsibility has a predictable blast radius. A bug affects only the operations that service performs. A dependency change affects only what that dependency provides to this service.
The Cohesion Test
A useful test: can you describe the service's responsibility in one sentence without using "and"?
"The InvitationService manages invitations" — a pass.
"The InvitationService creates invitations, validates permissions, auto-accepts pending invitations, creates memberships, sends emails, and manages encryption context" — a fail. Each additional responsibility joined by "and" is a candidate for extraction.
When cross-domain coordination is the cause — one service needs to trigger another domain's logic — domain events are the correct resolution. The kitchen sink service's side effects (sending email, creating memberships) become independent handlers subscribed to an event published by the service's core operation. The service shrinks to its actual responsibility.
Read Next
- Inter-Service Coupling — How service injection causes services to absorb cross-domain responsibilities
- Event-Driven Patterns — Domain events as an alternative to orchestration inside large services
- Domain-Driven Design — Bounded contexts and aggregate design as the structural answer to service boundaries