Why this matters
The peripheral anti-pattern is named after the Paris ring road (le périphérique): a route that lets traffic travel around the city without ever entering it. In software, the equivalent is a controller that calls repositories, message queues, and external services directly — bypassing the domain use cases entirely. The domain layer exists on paper but receives no traffic.
How it happens: Repositories are public. No one enforces that controllers must go through use cases. A developer under time pressure writes a controller that does "just this one database call" directly. Then another, and another. Business rules accumulate in controllers and repositories — not in use cases. The use case layer becomes a thin pass-through or disappears entirely.
The consequence: business rules are scattered. To understand what happens when a user places an order, you must read the controller, three repositories, and two background tasks. None of it is testable without a live database and HTTP server. The domain layer is vestigial — present but unused.
The fix: controllers call use cases. Use cases call repositories (through ports). Repositories are package-private. The use case is the only entry point into the domain, and it owns all the business logic.
The problem
A controller makes three direct infrastructure calls with zero domain logic. Business rules are scattered across the controller. Use cases are empty. Domain layer is bypassed entirely.
Bad
from flask import request, jsonify
from orders.order_repository import OrderRepository
from payments.payment_repository import PaymentRepository
from emails.email_repository import EmailRepository
class OrderController:
def place_order(self):
data = request.json
order_repo = OrderRepository()
payment_repo = PaymentRepository()
email_repo = EmailRepository()
order = order_repo.find_by_id(data["order_id"]) # infrastructure
if order["status"] != "pending":
return jsonify({"error": "Order already processed"}), 400
if order["total"] > 1000: # business rule in controller!
payment_repo.flag_for_review(order["id"]) # infrastructure
payment_repo.charge(order["id"], order["total"]) # infrastructure
email_repo.send_confirmation(data["email"], order) # infrastructure
return jsonify({"status": "ok"})
# Controller: 20 lines. Use case: empty. Domain layer: bypassed.
# Testing requires real database, real payment gateway, and real SMTP server.
import { Request, Response } from "express";
import { OrderRepository } from "../orders/OrderRepository";
import { PaymentRepository } from "../payments/PaymentRepository";
import { EmailRepository } from "../emails/EmailRepository";
export class OrderController {
async placeOrder(req: Request, res: Response): Promise {
const { orderId, email } = req.body;
const order = await new OrderRepository().findById(orderId); // infrastructure
if (order.status !== "pending") {
res.status(400).json({ error: "Already processed" }); return;
}
if (order.total > 1000) { // business rule!
await new PaymentRepository().flagForReview(orderId); // infrastructure
}
await new PaymentRepository().charge(orderId, order.total); // infrastructure
await new EmailRepository().sendConfirmation(email, order); // infrastructure
res.json({ status: "ok" });
}
}
// Controller: 20 lines. Use case: empty. Domain layer: bypassed.
The solution
Controller calls one use case. The use case owns all business logic and orchestrates the repositories. Controller is 7 lines. Use case is fully testable with mock adapters.
Good
from flask import request, jsonify
from orders.place_order_use_case import PlaceOrderUseCase
class OrderController:
def place_order(self):
data = request.json
try:
result = PlaceOrderUseCase().execute(
order_id=data["order_id"], email=data["email"]
)
return jsonify(result)
except ValueError as e:
return jsonify({"error": str(e)}), 400
# All business logic in the use case
class PlaceOrderUseCase:
def __init__(self):
self._orders = _OrderRepository()
self._payments = _PaymentService()
self._emails = _EmailPort()
def execute(self, order_id: str, email: str) -> dict:
order = self._orders.find(order_id)
if order["status"] != "pending":
raise ValueError("Order already processed") # domain rule in domain
if order["total"] > 1000: # domain rule in domain
self._payments.flag_for_review(order_id)
self._payments.charge(order_id, order["total"])
self._emails.send_confirmation(email, order)
return {"status": "ok"}
# Controller: 7 lines. Use case: all the logic.
# Use case testable with mock adapters — no HTTP, no database required.
import { Request, Response } from "express";
import { PlaceOrderUseCase } from "../domain/PlaceOrderUseCase";
export class OrderController {
constructor(private readonly useCase: PlaceOrderUseCase) {}
async placeOrder(req: Request, res: Response): Promise {
try {
const result = await this.useCase.execute(req.body.orderId, req.body.email);
res.json(result);
} catch (e: any) {
res.status(400).json({ error: e.message });
}
}
}
// All business logic in the use case
export class PlaceOrderUseCase {
constructor(
private readonly orders: OrderRepository,
private readonly payments: PaymentService,
private readonly emails: EmailPort,
) {}
async execute(orderId: string, email: string): Promise<{ status: string }> {
const order = await this.orders.find(orderId);
if (order.status !== "pending") throw new Error("Order already processed");
if (order.total > 1000) await this.payments.flagForReview(orderId);
await this.payments.charge(orderId, order.total);
await this.emails.sendConfirmation(email, order);
return { status: "ok" };
}
}
// Controller: 7 lines. Use case: all the logic.
// Testable with InMemoryOrderRepository, MockPaymentService, MockEmailPort.
Key takeaway
If your controllers are long and your use cases are empty, your business logic has escaped to the periphery. Like the Paris ring road, all traffic flows around the domain without ever entering it. The domain layer exists but is irrelevant. Restore the rule: controllers call use cases, use cases own business rules, repositories implement ports — and make repositories package-private so the controller has no other choice.