Skip to main content
Clean Architecture 70 XP · 7 min

Partial Boundaries: Anticipation Strategies

Full architectural boundaries are expensive — partial boundaries using Strategy or Facade patterns preserve future separation points without the full upfront cost.

Showing
Ad (728×90)

Why this matters

A full architectural boundary — with bidirectional interfaces, separate components, and independent deployment — is expensive to build and maintain. In the early stages of a project, you may anticipate a boundary will be needed but cannot justify the cost yet. Partial boundaries are the answer: you build most of the pieces, but hold back the final separation step.

Robert Martin describes three partial boundary strategies. Skip the last step: build all the classes and interfaces needed for a full boundary, but keep them in a single component. One-dimensional boundary: use the Strategy pattern to isolate a direction of change — the boundary location is prepared, but only one side has an interface. Facade: place a simpler public interface in front of a complex subsystem to hide it from consumers.

The investment: these strategies cost less than a full boundary today, and much less than a painful refactor tomorrow. The future team that needs to extract the boundary into a separate service will find the seams already prepared.

The problem

Notification logic hardcoded with if/elif — every new channel requires editing this class, risking regressions in existing channels.

Bad

class NotificationService:
    def send(self, user_id: str, message: str, notification_type: str) -> None:
        if notification_type == "email":
            print(f"[EMAIL] to user {user_id}: {message}")
        elif notification_type == "sms":
            print(f"[SMS] to user {user_id}: {message}")
        elif notification_type == "push":
            print(f"[PUSH] to user {user_id}: {message}")
        else:
            raise ValueError(f"Unknown type: {notification_type}")

# Each new channel: open this class, add an elif, risk breaking the others.
export class NotificationService {
  send(userId: string, message: string, type: "email" | "sms" | "push"): void {
    if (type === "email") {
      console.log(\`[EMAIL] to \${userId}: \${message}\`);
    } else if (type === "sms") {
      console.log(\`[SMS] to \${userId}: \${message}\`);
    } else if (type === "push") {
      console.log(\`[PUSH] to \${userId}: \${message}\`);
    } else {
      throw new Error(\`Unknown type: \${type}\`);
    }
  }
}
// Every new channel: open this class, add an else-if, risk breaking the others.

The solution

A Strategy-based partial boundary — new notification channels are added as new classes; no existing code is ever modified. The boundary seam is prepared for future extraction.

Good

from abc import ABC, abstractmethod

class NotificationStrategy(ABC):
    @abstractmethod
    def send(self, user_id: str, message: str) -> None: ...

class EmailNotification(NotificationStrategy):
    def send(self, user_id: str, message: str) -> None:
        print(f"[EMAIL] to user {user_id}: {message}")

class SMSNotification(NotificationStrategy):
    def send(self, user_id: str, message: str) -> None:
        print(f"[SMS] to user {user_id}: {message}")

# Adding push: new class — zero changes to Email or SMS:
class PushNotification(NotificationStrategy):
    def send(self, user_id: str, message: str) -> None:
        print(f"[PUSH] to user {user_id}: {message}")

class NotificationService:
    def __init__(self, strategy: NotificationStrategy):
        self._strategy = strategy

    def send(self, user_id: str, message: str) -> None:
        self._strategy.send(user_id, message)

# Still one deployable unit (partial boundary).
# If needed later, each strategy can be extracted to its own service.
export interface NotificationStrategy {
  send(userId: string, message: string): void;
}

export class EmailNotification implements NotificationStrategy {
  send(userId: string, message: string): void {
    console.log(\`[EMAIL] to \${userId}: \${message}\`);
  }
}

export class SMSNotification implements NotificationStrategy {
  send(userId: string, message: string): void {
    console.log(\`[SMS] to \${userId}: \${message}\`);
  }
}

// Adding push: new class — zero changes to existing code:
export class PushNotification implements NotificationStrategy {
  send(userId: string, message: string): void {
    console.log(\`[PUSH] to \${userId}: \${message}\`);
  }
}

export class NotificationService {
  constructor(private readonly strategy: NotificationStrategy) {}

  send(userId: string, message: string): void {
    this.strategy.send(userId, message);
  }
}
// Partial boundary: one component today, ready to split into services tomorrow.

Key takeaway

A partial boundary is an investment in the future — it costs less than a full boundary today and much less than a refactor tomorrow. Use Strategy or Facade to mark where a boundary will eventually be needed, without paying the full price of a separate deployable component until the project genuinely requires it.

Done with this lesson?

Mark it complete to earn XP and track your progress.