Dependency Direction Violations
How the inward dependency rule breaks in practice — and why the API layer becomes a trap for business logic.
The Rule
In a four-layer architecture, dependencies point inward. The API layer depends on the application layer. The application layer depends on the domain. The infrastructure layer implements interfaces defined by inner layers. Nothing in the inner layers knows the outer layers exist.
This rule is simple to state and easy to violate.
How It Breaks Down
The most common violation is the API layer absorbing logic it shouldn't own. It happens gradually, and for understandable reasons: the request comes in at the API layer, so it feels natural to handle things there.
A route that starts as five lines grows to fifty. Business rules get added inline because "it's quicker." Orchestration logic that belongs in the application layer moves into the request handler because "we only need it here." A DI factory grows from constructing simple objects to containing complex configuration logic, conditional wiring, and business-relevant decisions about which implementations to use.
# The smell: a route handler doing too much
@router.post("/orders")
async def create_order(
request: CreateOrderRequest,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
# Business rule validation — belongs in the domain
if request.quantity > user.account_limit:
raise HTTPException(400, "Exceeds account limit")
# Orchestration — belongs in the application layer
inventory = await db.execute(select(Inventory).where(...))
if inventory.available < request.quantity:
raise HTTPException(400, "Insufficient inventory")
order = Order(user_id=user.id, quantity=request.quantity)
db.add(order)
# Side effect coordination — belongs in the application layer
await send_confirmation_email(user.email, order.id)
await update_inventory(db, request.product_id, request.quantity)
await db.commit()
return OrderResponse.from_orm(order)Each individual decision in this handler seemed defensible at the time. Together, they have turned the API layer into the application layer.
What Accumulates Over Time
Business rules become invisible. They live in route handlers, mixed with HTTP error codes, spread across multiple endpoints. There is no single place to understand what the business actually does.
Nothing is testable in isolation. To test the order creation logic, you must make an HTTP request, parse a response, and have a running database. The business rules cannot be exercised without the HTTP framework.
Business logic becomes inaccessible to other consumers. When a background worker or an event handler needs to create an order, it has two choices: duplicate the logic, or make an internal HTTP call to its own API. Neither is acceptable.
Infrastructure decisions become business decisions. When the database session is passed around the route handler, the fact that SQLAlchemy is the ORM becomes something the business logic depends on. Changing the database layer now requires touching business logic.
Why It Happens
There is no activation energy required to put something in the API layer. It is already open. The alternative — designing a proper application layer handler, defining a command object, keeping the route thin — requires more discipline and slightly more upfront work.
Over months and years of many contributors each making the "quick" choice, the API layer becomes the application layer, and the application layer becomes a shell that adds no value.
The Signal
Watch for:
- Route handlers longer than 10–15 lines of business logic
- Business rule conditions (
if x > limit,if status == "pending") inside route handlers - Direct database session access from within a route handler
- Orchestration of multiple operations in a single route (create this, then update that, then send email)
- A DI factory (
dependencies.py) with complex conditional logic or business-relevant configuration
The Correct Shape
The API layer should do one thing: translate between HTTP and the application layer. Receive a request, construct a command, call a handler, map the result to a response.
# Correct: the route is a thin translator
@router.post("/orders")
async def create_order(
request: CreateOrderRequest,
handler: CreateOrderHandler = Depends(get_create_order_handler),
):
command = CreateOrder(
user_id=request.user_id,
product_id=request.product_id,
quantity=request.quantity,
)
order = await handler.handle(command)
return OrderResponse.from_domain(order)All business rules, orchestration, and domain coordination belong in the application layer. The domain layer enforces invariants. The infrastructure layer handles side effects.
Read Next
- Layered Architecture — Layer responsibilities and correct interaction patterns
- Hexagonal Architecture — Ports & adapters as the mechanism for enforcing layer separation
- Infrastructure Concerns in the Business Layer — The same gravitational pull, from the other direction
- Inter-Service Coupling — What happens when orchestration accumulates in the API layer