Skip to main content
Clean Architecture 60 XP · 6 min

Organizational Strategies: Package by Feature

Group code by domain feature — top-level packages reveal the business, every feature is co-located, and searchability dramatically improves as the system grows.

Showing
Ad (728×90)

Why this matters

Package by Feature organizes code so that the directory structure reveals what the system does. Instead of controllers/, services/, repositories/, the top level reads orders/, users/, payments/. This is what Robert Martin calls Screaming Architecture — the structure announces the business domain, not the technology stack.

Advantages: All code related to Orders lives together. Finding "how does the order discount work?" means opening one directory instead of three. Deleting a feature means deleting one directory. Onboarding a new developer: "where does checkout live?" — in checkout/.

The limitation: Package by Feature improves discoverability but does not enforce boundaries. Inside orders/, a controller can still directly import a repository from users/. Feature packages can become tangled with each other through hidden imports. The structure is better, but the discipline must be added separately — through access modifiers, barrel files, or explicit ports.

The problem

Feature packages exist but boundaries are violated. orders/ reaches into users/ internals, bypassing the public API and creating hidden cross-feature coupling.

Bad

# orders/order_controller.py
from orders.order_service   import OrderService
from users.user_repository  import UserRepository  # ← crosses feature boundary!

class OrderController:
    def place_order(self):
        data    = request.json
        user_id = data["user_id"]

        # Reaches INTO the users/ package internals — bypasses UserService
        user_repo        = UserRepository()
        user             = user_repo.find_by_id(user_id)
        shipping_address = user.shipping_address  # raw entity from another feature

        return jsonify(OrderService().place(data, shipping_address))

# UserRepository is now coupled to OrderController.
# Refactoring UserRepository breaks OrderController — invisible dependency.
// orders/OrderController.ts
import { UserRepository } from "../users/UserRepository"; // ← crosses boundary!
import { OrderService }   from "./OrderService";

export class OrderController {
  async placeOrder(req: { body: { userId: string } }): Promise {
    const user    = await new UserRepository().findById(req.body.userId);
    const address = user.shippingAddress; // raw entity — bypasses UserService

    return new OrderService().place(req.body, address);
  }
}

// UserRepository is now coupled to OrderController.
// Refactoring UserRepository breaks OrderController — invisible dependency.
              
            
          
        

        

The solution

Feature packages expose only a public service API. Internals (like UserRepository) are never exported outside the package. Cross-feature communication uses the public API only.

Good

# users/user_service.py  ← public API of the users/ feature
from dataclasses import dataclass
from users._user_repository import UserRepository  # private, underscore prefix

@dataclass
class ShippingAddress:
    street: str
    city: str
    country: str

class UserService:
    def get_shipping_address(self, user_id: str) -> ShippingAddress:
        repo = UserRepository()
        user = repo.find_by_id(user_id)
        return ShippingAddress(street=user.street, city=user.city, country=user.country)

# orders/order_controller.py  ← uses only the public API
from users.user_service import UserService  # public API only
from orders.order_service import OrderService

class OrderController:
    def place_order(self):
        data    = request.json
        address = UserService().get_shipping_address(data["user_id"])
        return jsonify(OrderService().place(data, address))

# UserRepository can be refactored freely — OrderController never touches it.
// users/UserService.ts  ← public API of the users/ feature
export interface ShippingAddress { street: string; city: string; country: string; }

export class UserService {
  async getShippingAddress(userId: string): Promise {
    const repo = new UserRepository(); // internal
    const user = await repo.findById(userId);
    return { street: user.street, city: user.city, country: user.country };
  }
}

// users/index.ts  ← barrel: only exports public API
export { UserService } from "./UserService";
export type { ShippingAddress } from "./UserService";
// UserRepository is NOT exported

// orders/OrderController.ts  ← uses only the barrel (public API)
import { UserService } from "../users"; // barrel import only
import { OrderService } from "./OrderService";

export class OrderController {
  async placeOrder(req: { body: { userId: string } }): Promise {
    const address = await new UserService().getShippingAddress(req.body.userId);
    return new OrderService().place(req.body, address);
  }
}

// UserRepository can be refactored freely — OrderController never touches it.
            
          
        

        

Key takeaway

Package by Feature improves discoverability but doesn't enforce boundaries on its own. You need access modifiers or explicit barrel files to prevent features from reaching into each other's internals. The structure tells you where things are; access control tells you what can call what.

Done with this lesson?

Mark it complete to earn XP and track your progress.