Skip to main content
Clean Architecture 70 XP · 7 min

The Peripheral Anti-Pattern

The peripheral anti-pattern occurs when infrastructure code talks directly to other infrastructure code, bypassing the domain — like the Paris ring road, traffic flows around the city instead of through it.

Showing
Ad (728×90)

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.

Done with this lesson?

Mark it complete to earn XP and track your progress.