ADR 0002: Swappable Infrastructure via Adapters
Date: 2025-08-05
Status: Accepted
Context
Our microservices need to interact with numerous external systems. To maintain architectural flexibility, improve testability, and avoid vendor lock-in, we must prevent our core business logic from being tightly coupled to any specific infrastructure technology.
This decision is prompted by discussions confirming the need for swappable infrastructure. Our default stack is PostgreSQL for general persistence, NATS for messaging, and Redis for caching. However, services must be able to swap these for alternatives like Kafka, or specialized databases like KurrentDB (event store) and TigerBeetle (ledger store) where appropriate.
Decision
We will enforce the Ports and Adapters (Hexagonal) pattern for all external infrastructure interactions.
-
Ports (Interfaces): All external interactions must be defined as an interface within the
applicationordomainlayers. This interface (the "Port") describes the what and the why from the business perspective (e.g.,SaveTenant,PublishEvent). It must not expose any details of the underlying technology. -
Adapters (Implementations): The concrete implementation of a Port (the "Adapter") must reside exclusively in the
infrastructurelayer. This adapter contains the specific logic for interacting with a technology (e.g., GORM calls, Kafka client library). -
Dependency Inversion: The Dependency Inversion Principle will be strictly followed. The
applicationanddomainlayers define and depend on the Ports. Theinfrastructurelayer depends on and implements these Ports. This ensures the dependency flow is always directed inwards, protecting the core domain. -
Configuration-based Wiring: The Dependency Injection (DI) container (
container.go) is responsible for instantiating the correct Adapter and injecting it where the Port interface is required. The choice of which Adapter to use will be driven by configuration (e.g., environment variables).
Examples
| Layer | Port (Interface) | Default Adapter (Implementation) | Alternative Adapter(s) |
|---|---|---|---|
| Persistence | domain/identity/repositories/TenantRepository | infrastructure/persistence/PostgresTenantRepository | MongoTenantRepository |
| Identity | domain/identity/IdPAdapter | infrastructure/idp/RAuthAdapter | KeycloakAdapter |
| Messaging | application/messaging/EventPublisher | infrastructure/messaging/NatsEventPublisher | KafkaEventPublisher |
| Caching | application/caching/CacheStore | infrastructure/caching/RedisCacheStore | MemcachedCacheStore |
| Event Store | domain/shared_kernel/EventStore | infrastructure/event_stores/PostgresEventStore | KurrentDBEventStore |
| Ledger Store | domain/ledger/LedgerStorageService | infrastructure/ledger_storage/PostgresLedgerAdapter | TigerBeetleLedgerAdapter |
Consequences
Positive
- High Testability: The core application logic can be unit-tested by providing mock implementations of the Port interfaces, eliminating the need for running external systems.
- Enhanced Flexibility: We can switch from Kafka to NATS, or PostgreSQL to another database, by simply writing a new Adapter and changing a configuration value.
- Clear Boundaries: This pattern enforces the separation of concerns defined in
ADR 0001, making the codebase easier to understand and maintain. - Parallel Development: Teams can work on the core application logic and the infrastructure adapters simultaneously, as long as they agree on the Port interface contract.
Negative
- Increased Boilerplate: This pattern requires defining an interface for every external interaction, which can add a small number of extra files. This is a deliberate and accepted trade-off for the significant benefits in maintainability and flexibility.