Ports & Adapters — Interface Your App, Adapt the World
Port = interface your application defines. Adapter = implementation connecting to the outside world. Swap adapters without touching the app.
When to use
- When you need to swap external systems (DB, email, messaging) without modifying business logic
- When testing requires in-memory replacements for external dependencies
Tradeoffs
- One interface per external boundary (more files, more wiring)
- DI container complexity grows with number of adapters
- Go
- Python
// Port — defined in domain
type NotificationPort interface{ Notify(userID, msg string) error }
// Adapter 1: in-memory (test)
type InMemoryNotifier struct{ Sent []string }
func (n *InMemoryNotifier) Notify(uid, msg string) error {
n.Sent = append(n.Sent, msg); return nil
}
// Adapter 2: real (infrastructure)
type EmailNotifier struct{ client *smtp.Client }
func (e *EmailNotifier) Notify(uid, msg string) error {
return sendEmail(e.client, uid, msg)
}
// Wiring at startup (main)
func main() { app := NewApp(NewEmailNotifier(smtpClient)) }
from abc import ABC, abstractmethod
# Port
class NotificationPort(ABC):
@abstractmethod
def notify(self, user_id: str, msg: str) -> None: ...
# Adapter 1: in-memory (test)
class InMemoryNotifier(NotificationPort):
def __init__(self): self.sent: list[str] = []
def notify(self, user_id: str, msg: str) -> None:
self.sent.append(msg)
# Adapter 2: real (infrastructure)
class EmailNotifier(NotificationPort):
def notify(self, user_id: str, msg: str) -> None:
send_email(user_id, msg)
# Wiring at startup
app = App(notifier=EmailNotifier())
Gotcha: Primary adapters DRIVE the app (HTTP handler calls your app). Secondary adapters are DRIVEN by the app (your app calls the DB). The direction matters for testing.