Skip to main content
Clean Architecture 70 XP · 7 min

Package by Component: The Hybrid Approach

Group all responsibility for a clean feature boundary into one package — internal persistence and logic stay private, the compiler enforces what's visible to the rest of the system.

Showing
Ad (728×90)

Why this matters

Package by Component is Simon Brown's synthesis of Package by Layer and Package by Feature. A component is a coarse-grained unit with a well-defined public interface and hidden internals. Everything required to implement one complete domain boundary — controller, service, repository, entity — lives in one package. The key addition over Package by Feature: only the service is public. Everything else is package-private.

The compiler becomes the enforcer. In Java, package-private access means no other package can access the class even if they know its name. In TypeScript, a barrel index.ts that exports only the service makes the repository invisible to the rest of the system — any import of the internal file is caught by ESLint rules. In Python, underscore-prefixed modules and __all__ provide the same intent.

This is packaging by architectural boundary, not by technical layer or domain feature. Each component owns its full vertical slice and enforces its own surface area.

The problem

OrderRepository is fully public. A PaymentsController imports it directly — bypassing OrderService and all its business logic.

Bad

# orders/order_repository.py  ← should be internal, but is fully public
class OrderRepository:
    def find_by_id(self, order_id: str) -> dict:
        return {"id": order_id, "status": "pending", "total": 99.0}

# orders/order_service.py  ← intended public API
class OrderService:
    def get_order_status(self, order_id: str) -> str:
        return OrderRepository().find_by_id(order_id)["status"]

# payments/payments_controller.py  ← violates the component boundary
from orders.order_repository import OrderRepository  # ← bypasses OrderService!

class PaymentsController:
    def process(self, order_id: str):
        order = OrderRepository().find_by_id(order_id)  # direct internal access
        # ... no business rules applied, no validation ...

# The component has no boundary — it's just a folder with a name.
// orders/OrderRepository.ts  ← should be internal, but is exported
export class OrderRepository {
  findById(orderId: string): { id: string; status: string; total: number } {
    return { id: orderId, status: "pending", total: 99.0 };
  }
}

// orders/OrderService.ts  ← intended public API
export class OrderService {
  getOrderStatus(orderId: string): string {
    return new OrderRepository().findById(orderId).status;
  }
}

// payments/PaymentsController.ts  ← violates the component boundary
import { OrderRepository } from "../orders/OrderRepository"; // ← bypasses service!

export class PaymentsController {
  process(orderId: string): void {
    const order = new OrderRepository().findById(orderId); // direct internal access
    // no business rules applied
  }
}

// The component has no boundary — it's just a folder with a name.

The solution

Only OrdersService is exported from the component. OrderRepository is package-private. Payments can only call OrdersService.get_order_status() — the boundary is compiler-enforced.

Good

# orders/__init__.py  ← enforced public API
from orders._order_service import OrderService

__all__ = ["OrderService"]  # only OrderService is exported

# orders/_order_repository.py  ← private (underscore prefix)
class _OrderRepository:
    def find_by_id(self, order_id: str) -> dict:
        return {"id": order_id, "status": "pending", "total": 99.0}

# orders/_order_service.py  ← private implementation
from orders._order_repository import _OrderRepository

class OrderService:
    def get_order_status(self, order_id: str) -> str:
        return _OrderRepository().find_by_id(order_id)["status"]

    def place_order(self, data: dict) -> dict:
        order = {"id": data["id"], "total": data["total"], "status": "pending"}
        _OrderRepository().save(order)
        return order

# payments/payments_controller.py  ← only sees the public API
from orders import OrderService  # _OrderRepository is not accessible

class PaymentsController:
    def process(self, order_id: str):
        status = OrderService().get_order_status(order_id)  # goes through business logic
// orders/OrderRepository.ts  ← NOT in the barrel (internal only)
class OrderRepository {  // no export keyword at top level
  findById(orderId: string): { id: string; status: string; total: number } {
    return { id: orderId, status: "pending", total: 99.0 };
  }
}
export { OrderRepository };  // visible inside orders/ only

// orders/OrdersService.ts
import { OrderRepository } from "./OrderRepository";

export class OrdersService {
  getOrderStatus(orderId: string): string {
    return new OrderRepository().findById(orderId).status;
  }

  placeOrder(data: { id: string; total: number }): object {
    const order = { id: data.id, total: data.total, status: "pending" };
    // new OrderRepository().save(order);
    return order;
  }
}

// orders/index.ts  ← barrel: only public surface
export { OrdersService } from "./OrdersService";
// OrderRepository is NOT re-exported

// payments/PaymentsController.ts  ← only sees the barrel
import { OrdersService } from "../orders"; // barrel import

export class PaymentsController {
  process(orderId: string): void {
    const status = new OrdersService().getOrderStatus(orderId); // through business logic
  }
}

// ESLint: import/no-internal-modules catches any "../orders/OrderRepository" import.

Key takeaway

If internal classes of a component are public, the component has no boundary — it is just a folder with a name. Use access modifiers, barrel files, and linting rules to enforce that the only entry point to a component is its declared public API. The compiler should make violations impossible, not merely impolite.

Done with this lesson?

Mark it complete to earn XP and track your progress.