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.