Inter-Service Coupling
The fundamental coupling problem in layered architectures — and why the three common escape hatches each make things worse.
The Tension
In any application of meaningful size, a point arrives where one domain's business logic needs to trigger or coordinate with another domain's business logic. A user registers and should be enrolled in an onboarding flow. An order is placed and inventory needs updating. An account is deleted and data across several domains needs cleaning up.
This is not a design failure — it is an inevitable property of interconnected domains. The question is how the coupling is expressed.
In layered architectures, three escape hatches are commonly reached for. Each solves the immediate problem. Each creates a larger problem downstream.
Escape Hatch 1: Orchestration in the API Layer
The simplest response: handle it in the route handler. Call ServiceA, then ServiceB, coordinate the result.
@router.post("/users/register")
async def register_user(
request: RegisterRequest,
user_service: UserService = Depends(get_user_service),
onboarding_service: OnboardingService = Depends(get_onboarding_service),
email_service: EmailService = Depends(get_email_service),
):
user = await user_service.create(request.email, request.password)
await onboarding_service.enroll(user.id)
await email_service.send_welcome(user.email)
return UserResponse.from_domain(user)This works. It also means the coordination logic only exists inside an HTTP request. A background job or admin script that needs to register a user must either make an HTTP call or duplicate the orchestration sequence. The API layer has absorbed domain knowledge — it now understands the relationship between user creation, onboarding, and email. Any other trigger for the same operation requires reproducing that knowledge elsewhere.
See Dependency Direction Violations for the broader pattern this falls into.
Escape Hatch 2: Injecting Services into Services
A cleaner-feeling alternative: let ServiceA depend on ServiceB directly.
class UserService:
def __init__(
self,
user_repository: IUserRepository,
onboarding_service: OnboardingService,
email_service: EmailService,
):
self._users = user_repository
self._onboarding = onboarding_service
self._email = email_service
async def register(self, email: str, password: str) -> User:
user = User.create(email, password)
await self._users.save(user)
await self._onboarding.enroll(user.id)
await self._email.send_welcome(user.email)
return userThe coordination is now in the application layer, the route is thin. The first few couplings feel justified. The problem is structural, not immediate.
The circular dependency time bomb
Each service-to-service dependency is introduced for a reason that makes sense at the time. UserService → OnboardingService because user creation should trigger onboarding. Later, OnboardingService → UserService because an onboarding step needs to look up user details. Each coupling, in isolation, is defensible. Together, they form a cycle the runtime cannot resolve.
The usual response is to extract a third service to break the cycle — which inherits the same pressures and eventually faces the same problem.
Even without a cycle, the accumulation is damaging. A service with three injected services requires constructing all three to test it. Each of those services may have their own dependencies. Testing the outer service requires setting up the full graph. Teams respond by mocking, and tests gradually diverge from production behaviour.
The optional dependency as a signal
One common attempt to manage this without resolving it architecturally:
class UserService:
def __init__(
self,
user_repository: IUserRepository,
onboarding_service: Optional[OnboardingService] = None, # ← optional
):
self._onboarding = onboarding_service
async def register(self, ...):
user = ...
if self._onboarding: # ← null guard
await self._onboarding.enroll(user.id)The null guard signals that the developer recognised the coupling was problematic. Rather than resolving it, they made it conditional. The service still knows about OnboardingService. The coupling is now implicit rather than explicit — and invisible in tests that construct the service without the optional dependency.
Escape Hatch 3: Business Logic in the Repository Layer
A different direction: rather than coupling at the service level, shared logic is pushed into the repository layer, usually under the banner of keeping things DRY.
This most commonly manifests as access control checks or cross-domain queries embedded in repository methods. See Business Logic in Repositories for this pattern in depth.
The Root Cause
All three escape hatches are symptoms of the same underlying gap: no mechanism for cross-domain coordination that respects the inward dependency rule.
The solution is to invert the coupling. Rather than ServiceA calling ServiceB directly, ServiceA publishes a domain event. ServiceB subscribes to that event and acts independently. Neither service knows the other exists.
# UserService: publishes what happened, has no knowledge of downstream effects
class UserService:
async def register(self, email: str, password: str) -> User:
user = User.create(email, password)
await self._users.save(user)
user.publish(UserRegistered(user_id=user.id, email=user.email))
return user
# OnboardingEnrollmentHandler: reacts independently
class OnboardingEnrollmentHandler:
async def handle(self, event: UserRegistered) -> None:
await self._onboarding.enroll(event.user_id)The domains are loosely coupled. Adding a new reaction to user registration requires no changes to UserService. Removing the onboarding step does not affect user creation. Each handler is independently testable.
Read Next
- Event-Driven Patterns — Domain events, the EventPublisher port, and adapter selection
- Business Logic in Repositories — Escape hatch 3 in depth
- Kitchen Sink Services — What services look like when this pattern continues unchecked
- Dependency Direction Violations — How escape hatch 1 degrades the API layer