Skip to main content

Sign in to CleanKata

Track your progress, earn XP, and unlock every lesson.

By signing in you agree to our Terms of Use and Privacy Policy.

Clean Architecture70 XP7 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.

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.

🔧 Some exercises may still have errors. If something seems wrong, use the Feedback button (bottom-right of the page) to report it — it helps us fix it fast.

Hint: A partial boundary is an investment in the future — it costs less than a full boundary today and much less than a refactor tomorrow.

✗ Your version

Partial Boundaries: Anticipation Strategies — CleanKata — CleanKata