DEV Community

Multicloud Execution Portability: Abstracting AWS DynamoDB and Azure Cosmos DB via Hexagonal Architecture

Monolithic data access layers inherently bind compute execution to a specific cloud provider's proprietary database SDK. When an enterprise application tightly couples its business logic to Amazon Web Services (AWS) DynamoDB using boto3, attempting to failover execution to Microsoft Azure Cosmos DB during a systemic AWS outage becomes an insurmountable engineering challenge. True multicloud resilience demands that the application execution layer remains entirely ignorant of its underlying storage mechanism. By enforcing a strict Hexagonal Architecture paradigm, engineering teams can decouple the core domain from vendor specific infrastructure. Implementing pure Python output ports allows the runtime to dynamically inject either a DynamoDB adapter or a Cosmos DB adapter without altering a single line of business logic. This architectural abstraction guarantees execution portability, enabling workloads to shift seamlessly across cloud boundaries while neutralizing database vendor lock-in.

Prerequisites

Deploying this cloud agnostic data abstraction requires a deep understanding of Python object oriented programming and interface design. The compute environment requires Python 3.12. Vendor integration relies on the boto3 library version 1.34.0 for AWS interactions and the azure-cosmos library version 4.5.1 for Azure interactions. Infrastructure provisioning requires Terraform version 1.7.0 or higher, utilizing the HashiCorp AWS Provider version 5.40.0 and the AzureRM Provider version 3.90.0. Ensure the execution environment possesses the necessary IAM roles for AWS and managed identities for Azure AD authentication.

Step-by-Step Implementation

Defining the Core Domain and Outbound Port

The foundation of execution portability begins by explicitly defining the domain invariants and isolating them from external dependencies. We construct a pure Python dataclass to represent the core business entity and an Abstract Base Class (ABC) to serve as the outbound port. The architectural justification for this absolute decoupling is to protect the domain from the volatile nature of vendor APIs. The TransactionRepositoryPort must never accept or return boto3 dictionaries or Azure Cosmos response objects. It operates exclusively using pure domain data transfer objects. If the domain layer imports an AWS SDK, the hexagonal boundary is breached, and multicloud portability is instantly destroyed. By establishing a rigid interface contract, we force all future database implementations to bend to the will of the domain, rather than bending the domain to accommodate the database.

from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Optional
from datetime import datetime, timezone

@dataclass(frozen=True)
class TransactionEntity:
    transaction_id: str
    account_id: str
    amount: float
    timestamp: str

class TransactionRepositoryPort(ABC):
    @abstractmethod
    def save(self, transaction: TransactionEntity) -> None:
        pass

    @abstractmethod
    def find_by_id(self, transaction_id: str, account_id: str) -> Optional[TransactionEntity]:
        pass

class TransactionService:
    def __init__(self, repository: TransactionRepositoryPort):
        self.repository = repository

    def process_new_transaction(self, account_id: str, amount: float) -> TransactionEntity:
        if amount <= 0:
            raise ValueError("Transaction amount must be strictly positive.")

        entity = TransactionEntity(
            transaction_id=f"txn_{int(datetime.now(timezone.utc).timestamp())}",
            account_id=account_id,
            amount=amount,
            timestamp=datetime.now(timezone.utc).isoformat()
        )

        self.repository.save(entity)
        return entity

Enter fullscreen mode Exit fullscreen mode

If the domain logic is now completely shielded from the infrastructure APIs, how do we translate these pure Python data structures into the proprietary JSON formats demanded by AWS and Azure respectively?

Implementing Vendor Specific Infrastructure Adapters

We translate the domain requests into proprietary vendor operations by writing dedicated, highly specialized infrastructure adapters that implement our abstract port. We construct a DynamoDbAdapter utilizing boto3 and a CosmosDbAdapter utilizing the azure-cosmos client. The architectural necessity here is to encapsulate all SDK complexity, retry logic, and exception handling within these distinct classes. When the domain invokes the save method, the DynamoDB adapter formats the payload using the explicit boto3 parameters, while the Cosmos DB adapter formats the payload into a standard JSON document incorporating the required id and partition key fields. These adapters act as the translation layer, ensuring that the idiosyncrasies of AWS request units and Azure throughput configurations never contaminate the core application memory space.

resource "aws_dynamodb_table" "multicloud_transactions" {
  name         = "Transactions"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "AccountId"
  range_key    = "TransactionId"

  attribute {
    name = "AccountId"
    type = "S"
  }
  attribute {
    name = "TransactionId"
    type = "S"
  }
}

resource "azurerm_cosmosdb_sql_container" "multicloud_transactions" {
  name                  = "Transactions"
  resource_group_name   = azurerm_resource_group.core.name
  account_name          = azurerm_cosmosdb_account.global.name
  database_name         = azurerm_cosmosdb_sql_database.core.name
  partition_key_paths   = ["/account_id"]
  partition_key_version = 2
}

Enter fullscreen mode Exit fullscreen mode
import boto3
from botocore.exceptions import ClientError
from azure.cosmos import CosmosClient
from azure.cosmos.exceptions import CosmosHttpResponseError

class DynamoDbAdapter(TransactionRepositoryPort):
    def __init__(self, table_name: str, region_name: str = "us-east-1"):
        self.dynamodb = boto3.resource('dynamodb', region_name=region_name)
        self.table = self.dynamodb.Table(table_name)

    def save(self, transaction: TransactionEntity) -> None:
        try:
            self.table.put_item(
                Item={
                    'AccountId': transaction.account_id,
                    'TransactionId': transaction.transaction_id,
                    'Amount': str(transaction.amount),
                    'Timestamp': transaction.timestamp
                }
            )
        except ClientError as e:
            raise RuntimeError(f"AWS DynamoDB write failed: {e.response['Error']['Message']}")

    def find_by_id(self, transaction_id: str, account_id: str) -> Optional[TransactionEntity]:
        response = self.table.get_item(Key={'AccountId': account_id, 'TransactionId': transaction_id})
        item = response.get('Item')
        if not item:
            return None
        return TransactionEntity(
            transaction_id=item['TransactionId'],
            account_id=item['AccountId'],
            amount=float(item['Amount']),
            timestamp=item['Timestamp']
        )

class CosmosDbAdapter(TransactionRepositoryPort):
    def __init__(self, endpoint: str, key: str, database_name: str, container_name: str):
        self.client = CosmosClient(endpoint, credential=key)
        self.database = self.client.get_database_client(database_name)
        self.container = self.database.get_container_client(container_name)

    def save(self, transaction: TransactionEntity) -> None:
        try:
            document = {
                "id": transaction.transaction_id,
                "account_id": transaction.account_id,
                "amount": transaction.amount,
                "timestamp": transaction.timestamp
            }
            self.container.upsert_item(body=document)
        except CosmosHttpResponseError as e:
            raise RuntimeError(f"Azure Cosmos DB write failed: {e.message}")

    def find_by_id(self, transaction_id: str, account_id: str) -> Optional[TransactionEntity]:
        try:
            item = self.container.read_item(item=transaction_id, partition_key=account_id)
            return TransactionEntity(
                transaction_id=item['id'],
                account_id=item['account_id'],
                amount=float(item['amount']),
                timestamp=item['timestamp']
            )
        except CosmosHttpResponseError as e:
            if e.status_code == 404:
                return None
            raise RuntimeError(f"Azure Cosmos DB read failed: {e.message}")

Enter fullscreen mode Exit fullscreen mode

When multiple infrastructure adapters exist within the codebase, how does the application seamlessly instantiate the correct cloud specific implementation during an active disaster recovery failover without requiring a manual code redeployment?

Dynamic Runtime Resolution and Dependency Injection

We achieve zero downtime adapter switching by implementing a dynamic dependency injection factory that resolves the target infrastructure at application startup. The architectural reasoning is operational agility. During a critical cloud degradation event, operators cannot wait for deployment pipelines to build and deploy modified application images to Azure. By reading an environment variable injected by the orchestrator, the application dynamically resolves which adapter to instantiate and pass into the TransactionService. This topology ensures that the identical Docker image functions perfectly in both the AWS container execution environments and the Azure container execution environments. The AWS deployment initializes the DynamoDbAdapter, while the Azure deployment initializes the CosmosDbAdapter. The domain logic remains entirely oblivious to this injection phase, continuing to process transactions securely and deterministically regardless of its physical execution location.

Sequence Diagram

import os

class RepositoryFactory:
    @staticmethod
    def get_repository() -> TransactionRepositoryPort:
        active_cloud = os.environ.get("ACTIVE_CLOUD_PROVIDER", "AWS").upper()

        if active_cloud == "AWS":
            return DynamoDbAdapter(
                table_name=os.environ["DYNAMODB_TABLE_NAME"],
                region_name=os.environ.get("AWS_REGION", "us-east-1")
            )
        elif active_cloud == "AZURE":
            return CosmosDbAdapter(
                endpoint=os.environ["COSMOS_ENDPOINT"],
                key=os.environ["COSMOS_KEY"],
                database_name=os.environ["COSMOS_DB_NAME"],
                container_name=os.environ["COSMOS_CONTAINER_NAME"]
            )
        else:
            raise ValueError(f"Unsupported cloud provider configuration: {active_cloud}")

# Application Bootstrap
repository_port = RepositoryFactory.get_repository()
transaction_service = TransactionService(repository_port)

# Domain execution remains identical regardless of the resolved adapter
result = transaction_service.process_new_transaction(account_id="ACC_9918", amount=250.00)
print(f"Transaction stored safely. Provider agnostic ID: {result.transaction_id}")

Enter fullscreen mode Exit fullscreen mode

How do platform operators mitigate data structure failures when the primary key conventions differ fundamentally between DynamoDB composite keys and Cosmos DB document IDs?

Common Troubleshooting

When implementing cross cloud database abstraction, primary key translation failures are a dominant source of runtime errors. If the Cosmos DB adapter returns an HTTP 400 Bad Request citing partition key mismatch, you must verify the document id property. DynamoDB natively supports composite primary keys defined by a Hash Key and a Range Key. Cosmos DB requires a strict lowercase id field mapped to a unique string, alongside a separate, explicitly defined partition key. The Python Cosmos adapter must manually construct this id mapping, ensuring the domain's unique identifier is bound to the id property of the JSON dictionary before executing the upsert operation.

Another critical issue surfaces as extreme latency degradation during connection initialization. If the DynamoDbAdapter performs rapidly but the CosmosDbAdapter consistently times out on the initial request within an Azure execution context, check the SDK connectivity mode. The Python azure-cosmos client may default to Gateway Mode, traversing extra proxy layers. Ensure your Azure networking is configured to support Direct Mode connectivity on TCP ports 10000 to 20000, allowing the client to communicate directly with the Cosmos DB backend storage replicas, significantly reducing round trip latency.

Conclusion

Abstracting cloud specific data services via Hexagonal Architecture establishes a resilient, vendor agnostic execution layer capable of surviving total provider degradation. By isolating domain logic from the boto3 and azure-cosmos SDKs through strict Python output ports, engineering teams can dynamically pivot transactional workloads across AWS and Azure environments seamlessly. To complete the multicloud disaster recovery posture, architectures should deploy a background replication mesh bridging the two data stores utilizing DynamoDB Streams and Azure Event Grid to guarantee eventual data consistency alongside this compute portability.

References

Cockburn, A. (2005). Hexagonal architecture. Alistair Cockburn. https://alistair.cockburn.us/hexagonal-architecture/

Vernon, V. (2013). Implementing domain-driven design. Addison-Wesley Professional.

Top comments (0)