Skip to main content

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.

  1. Ports (Interfaces): All external interactions must be defined as an interface within the application or domain layers. 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.

  2. Adapters (Implementations): The concrete implementation of a Port (the "Adapter") must reside exclusively in the infrastructure layer. This adapter contains the specific logic for interacting with a technology (e.g., GORM calls, Kafka client library).

  3. Dependency Inversion: The Dependency Inversion Principle will be strictly followed. The application and domain layers define and depend on the Ports. The infrastructure layer depends on and implements these Ports. This ensures the dependency flow is always directed inwards, protecting the core domain.

  4. 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

LayerPort (Interface)Default Adapter (Implementation)Alternative Adapter(s)
Persistencedomain/identity/repositories/TenantRepositoryinfrastructure/persistence/PostgresTenantRepositoryMongoTenantRepository
Identitydomain/identity/IdPAdapterinfrastructure/idp/RAuthAdapterKeycloakAdapter
Messagingapplication/messaging/EventPublisherinfrastructure/messaging/NatsEventPublisherKafkaEventPublisher
Cachingapplication/caching/CacheStoreinfrastructure/caching/RedisCacheStoreMemcachedCacheStore
Event Storedomain/shared_kernel/EventStoreinfrastructure/event_stores/PostgresEventStoreKurrentDBEventStore
Ledger Storedomain/ledger/LedgerStorageServiceinfrastructure/ledger_storage/PostgresLedgerAdapterTigerBeetleLedgerAdapter

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.