Back to Community Blog

One Codebase, Two Clouds

authorImage

Mounika Gampa

Jul 13, 2026

14 min read

featuredImage
Two Deployments, One Source Tree

How we run the same financial onboarding service across two independent production deployments — one on AWS, one on GCP — without forking the code, leaking database concerns into business logic, or compromising on encryption and atomicity.

01The problem we didn't want to solve twice

Onboarding a user into a regulated financial product is not a single API call. It is a sequence of durable checkpoints — verify an email, verify a phone, run identity and document checks, screen against compliance rules, capture legal consent — and every step must be durable, auditable, and replayable. If a user drops off after step four and returns an hour later, the service must know exactly where they were, pick up seamlessly, and do so without double-charging a downstream call or losing a piece of verified data.

That makes the persistence layer the backbone of the entire service. It holds the state machine for every in-flight onboarding, the full audit trail of every decision, and the encrypted personal data collected along the way.

Now add a cross-cutting requirement: the same service must run across two independent production environments — one on AWS, one on Google Cloud — each serving a distinct market and regulatory context. In the first, the persistence layer is DynamoDB. In the second, it is Cloud Spanner. Same onboarding logic, same compliance guarantees, completely different database APIs underneath.

DimensionDeployment A (AWS)Deployment B (GCP)
DatabaseDynamoDB — NoSQL key–valueCloud Spanner — Relational SQL
TransactionsTransactWriteItems (item-level)Read-write transactions (multi-row)
Query languagePartiQL / proprietaryStandard SQL
DistributionRegional, partition-basedGlobally distributed
Client SDKAWS SDK v2 (Java)GCP Spanner Java client

The naive options were both unacceptable:

  • Fork per deployment — Two codebases drift immediately. Every feature ships twice and every bug gets fixed once. Compliance changes — inevitable in a regulated financial product — must land in sync across both forks.
  • Branch in business logic — Sprinkling if (cloud == AWS) throughout the service leaks database concerns into the workflow engine. The part that should only care about onboarding checkpoints ends up knowing about TransactWriteItems and Spanner mutations.

Our approach — Build the seam once

Write the onboarding logic once against a database-shaped interface. Let targeted per-deployment overrides handle the small number of features that genuinely diverge. The cloud becomes a compile-time dependency, not a runtime branch.

02Two deployments, one source tree

Before diving into the implementation, it helps to be precise about what “two deployments” means in practice. These are not a failover pair or an active–active replication setup. They are two independent production environments, each serving a distinct market and regulatory context, built from the same source tree and released on the same pipeline.

DimensionDeployment ADeployment B
CloudAWSGoogle Cloud
DatabaseDynamoDBCloud Spanner
Transaction APITransactWriteItemsRead-write transactions (SQL)
Key managementAWS KMSGoogle Cloud KMS
Idempotency TTLItem-level TTL attributeRow deletion policy on TIMESTAMP
Business logicIdentical — shared codebaseIdentical — shared codebase

Most of the service is truly identical across both deployments. The divergences are surgical: the transaction primitive, the KMS provider, and how the idempotency TTL maps to each database's data model. Everything else — the state machine, the audit event outbox, the compliance rules, the entity dirty-tracking model — ships as shared code, unchanged, to both environments.

Feature divergence map

FeatureDeployment A (AWS)Deployment B (GCP)
Checkpoint state machineShared

Same logic, same transitions

Shared

Same logic, same transitions

Audit event recordingShared

Transactional outbox pattern

Shared

Transactional outbox pattern

Atomic multi-writePer-deployment

TransactWriteItems (DynamoDB)

Per-deployment

Read-write transaction (Spanner)

Field-level encryptionPer-deployment

AWS KMS — envelope DEK/KEK

Per-deployment

Cloud KMS — envelope DEK/KEK

Idempotency key TTLPer-deployment

Item-level TTL attribute

Per-deployment

Row deletion policy on TIMESTAMP

Compliance rulesShared

Same policy engine

Shared

Same policy engine

Dirty trackingShared

DAL-managed entity model

Shared

DAL-managed entity model

The goal of the design is to keep the per-deployment surface as narrow as possible, and to express every divergence through the same abstraction mechanisms rather than as ad-hoc branches in business code.

03The shape of the abstraction

The core pattern is a thin Data Access Layer (DAL) that the application talks to exclusively through repository interfaces. Business code never imports a cloud SDK — it imports a repository. Provider implementations are swapped at build time through Maven profiles.

The repository interface exposes two families of methods. The distinction drives the entire cross-cloud transaction story:

Java · OnboardingTransactionRepositoryClient.java
@Repository
public interface OnboardingTransactionRepositoryClient {

    // Direct methods — execute immediately against the database.
    OnboardingTransactionEntity save(OnboardingTransactionEntity entity);
    OnboardingTransactionEntity update(OnboardingTransactionEntity entity);
    List<OnboardingTransactionEntity> findByUserId(String userId);
    OnboardingTransactionEntity lookupByTransactionId(String userId, String txId);

    // Deferred methods — return a *description* of a pending write.
    // The caller decides when and how to commit.
    DatabaseOperationContext<?, ?, ?> saveAsTransaction(...);
    DatabaseOperationContext<?, ?, ?> updateAsTransaction(...);
}

Provider selection is a build-time decision, not a runtime branch. A Maven profile determines which provider module lands on the classpath:

XML · pom.xml — build profiles
<!-- Deployment A: AWS artifact — zero GCP imports on classpath -->
<profile>
  <id>aws</id>
  <activation><activeByDefault>true</activeByDefault></activation>
  <dependency><artifactId>dal-dynamodb-provider</artifactId></dependency>
</profile>

<!-- Deployment B: GCP artifact — zero AWS imports on classpath -->
<profile>
  <id>gcp</id>
  <dependency><artifactId>dal-spanner-provider</artifactId></dependency>
</profile>

🔁 CI runs both profiles on every change

Build-time selection creates a forcing function: a change that breaks the Spanner provider cannot be merged green on DynamoDB alone. Both profiles build and both integration test suites must pass before any merge.

04Entities that remember they're managed

Abstracting the store surfaces an immediate practical problem: how do you perform efficient partial updates without re-serializing the whole record? Both DynamoDB and Spanner reward writing only the attributes that changed. To do that, the DAL needs to know what changed — it needs dirty tracking.

Business code works with a clean domain entity. That entity privately wraps the DAL's managed data object and the same instance is preserved across reads and writes. The rule is: mutate in place, never replace.

Partition keys and sort keys are immutable by construction — set once at record creation and throwing on any attempted mutation. A record that silently migrates its key is a record you have effectively lost. The invariant is cheap to enforce and miserable to debug after the fact.

05Transactions across two databases that disagree on what a transaction is

This is the requirement that nearly broke the abstraction. When onboarding advances a checkpoint, two writes must happen atomically: the transaction record updates and an audit event is written. If the record updates but the event is lost, the audit log lies. If the event is recorded but the state change fails, downstream systems act on a state that does not exist.

In Deployment A, DynamoDB expresses atomicity through TransactWriteItems. In Deployment B, Spanner expresses it through read-write transactions. Same guarantee, completely different APIs. The deferred operation pattern resolves this:

Java
TransactionManager txn = transactionManager.start();

// Describe the state change. Not executed yet.
DatabaseOperationContext<?, ?, ?> saveRecord =
    onboardingTransactionRepository.saveAsTransaction(transaction);

// Describe the audit event. Also not executed yet.
DatabaseOperationContext<?, ?, ?> recordEvent =
    eventPublisher.createTransactionalEvent(ONBOARDING_INITIATED, context);

txn.addOperation(saveRecord);
txn.addOperation(recordEvent);

// One commit. Both land, or neither does.
// The workflow engine has no idea which deployment it is running in.
txn.execute();

Under the hood, txn.execute() resolves differently per deployment:

  • Deployment A (DynamoDB): the provider folds both contexts into a single TransactWriteItems request.
  • Deployment B (Spanner): the provider applies both mutations inside one read-write transaction.

Both deployments deliver the same guarantee: both writes commit, or neither does. The workflow engine never learns which cloud it's on.

The event recorder as a transactional outbox

Events are first-class rows in the same database as the entities — not messages fired at a queue after a state change. The recorder exposes two modes:

Java · EventRecorder.java
// Best-effort: immediate publish, not atomic with a state change.
eventRecorder.publish(event);

// Transactional outbox: deferred — commits in the SAME transaction
// as the entity write. Works identically on both deployments.
DatabaseOperationContext<?, ?, ?> op = eventRecorder.publishAsTransaction(event);

Because the event write is just another DatabaseOperationContext in the same atomic commit as the state change, the event and the state it describes can never disagree — on either deployment. A separate relay process eventually delivers committed events outward, but the durability decision is made by the same transaction that changed the state.

06Encryption at rest, portably

Regulated financial onboarding means storing sensitive personal data. In some of the markets our deployments serve, specific identifiers require field-level encryption beyond the database's native at-rest protection. Both deployments use the same model — envelope encryption: a data encryption key (DEK) encrypts each field value, and a key encryption key (KEK) held in the cloud's KMS wraps the DEK. What differs between them is the KMS provider, isolated entirely behind a single Spring-wired utility bean:

Deployment A — AWS KMSDeployment B — Cloud KMS
Injected via @Profile("aws")Injected via @Profile("gcp")

– KmsClient from AWS SDK v2

– GenerateDataKey + Encrypt API

– Regional endpoint per market

– Shared in-memory DEK cache, bounded TTL

– KeyManagementServiceClient (GCP)

– Encrypt / Decrypt RPC API

– Key ring scoped per project

– Shared in-memory DEK cache, bounded TTL

Java · EncryptionConfig.java
@Configuration
public class EncryptionConfig {

    @Bean @Profile("aws")
    public WaasEncryptionUtil encryptionUtilAws(Environment env, KmsClient kms) {
        return new WaasEncryptionUtil(env, kms);
    }

    @Bean @Profile("gcp")
    public WaasEncryptionUtil encryptionUtilGcp(Environment env) {
        return new WaasEncryptionUtil(env, /* Cloud KMS provider */);
    }
}

Encryption becomes a property of the entity, not a concern callers must manage. Sensitive identifiers are encrypted on write and decrypted on read — transparently, in both deployments:

Java · OnboardingTransactionEntity.java — transparent encryption
// Callers never touch a key or call KMS directly.
public void setExternalAccountIdentifier(Map<String, String> identifiers) {
    Map<String, String> encrypted = CommonUtil.encryptPIIData(identifiers);
    dalTransaction.setExternalAccountIdentifier(encrypted);
}

public Map<String, String> getExternalAccountIdentifier() {
    return CommonUtil.decryptPIIData(dalTransaction.getExternalAccountIdentifier());
}

⚡ Cache the decrypted DEKs — in both deployments

A KMS round-trip per read is a latency and cost problem regardless of cloud. Both providers share the same in-memory DEK cache with a bounded size and a short TTL. Burst reads on the same record share the cached key; the TTL prevents key material from living in memory indefinitely. This caching logic lives in the shared DAL, not duplicated per deployment.

Two decisions that mattered in practice:

  1. Encrypt selectively.

Encrypting every field is tempting and counterproductive. It makes records unqueryable and burns latency on fields that do not need protection. Encrypt the identifiers that are genuinely sensitive and leave the rest plain.

  1. Identical encryption model across deployments.

Envelope encryption, selective fields, DEK caching — all of this is identical in both environments. Only the KMS provider behind the curtain changes.

07Idempotency, stored where everything else lives

Onboarding sits upstream of money movement and identity provisioning. A retried request must never trigger a side effect twice. Idempotency is implemented as a store-backed cache that routes through the same DAL repositories. The application code is identical across both deployments; only the TTL mechanism differs at the data-model level:

Java · IdempotencyService.java
public Optional<Response> checkIdempotencyKey(String key, String prefix) {
    return idempotencyKeyRepository.lookupById(prefix + key)
        .map(existing -> Response.ok(existing.getPayload()).build());
}

public void storeIdempotencyKey(String key, String prefix, String payload) {
    IdempotencyKey record = new IdempotencyKey();
    // Prefix scopes the key to this operation — prevents accidental
    // short-circuiting of a different call that reuses the same key string.
    record.setId(prefix + key);
    record.setPayload(payload); // Replayed verbatim on retry.
    record.setTimeCreatedMs(System.currentTimeMillis());
    idempotencyKeyRepository.save(record);
}
Deployment A — DynamoDB TTLDeployment B — Spanner TTL
Native item-level expiryRow deletion policy on TIMESTAMP column

– TTL attribute (Unix epoch seconds)

– Asynchronous deletion within ~48h

– No schema change or background job needed

– TIMESTAMP column + row deletion policy

– Background GC via Spanner internals

– Consistent with relational schema model

In both cases the application code is identical — write a record with a timestamp, look it up, replay on match. The TTL mechanism is a provider detail the service layer never has to think about. Because it is just another DAL repository, the idempotency store persists to DynamoDB or Spanner with zero cloud-specific code in the business layer.

08What we'd tell you before you build this

Running two production deployments off one source tree works well, but the design decisions that make it work are not obvious up front. Four principles we would share with any team attempting the same:

1. Abstract behaviors, not the database

The win was not hiding “DynamoDB vs Spanner” behind an interface. It was identifying the behaviors the domain actually needs — atomic multi-write, a transactional outbox, encrypted fields, idempotent retries — and giving each a cloud-neutral shape. DatabaseOperationContext did more for portability than any amount of CRUD wrapping.

2. Build-time selection over runtime branching

One provider per artifact means no dead code in production, no risk of talking to the wrong database, and a build matrix in CI that forces both deployments to stay green together. The cloud is a dependency you choose once — not a branch you carry forever.

3. The hard parts are the guarantees, not the queries

Reads and writes port trivially. Transactions, atomicity between a state change and its audit event, and key management are where two clouds diverge at the API level. Design those seams first — and express them through the abstraction before writing a single line of provider code.

4. Route every cross-cutting concern through the same rails

Encryption and idempotency are tempting to special-case. Routing them through the same deferred-operation and repository abstractions as the core domain logic is what kept the cloud-specific surface area small enough to test and reason about — in both deployments simultaneously.

The result is a single onboarding codebase where two independent production deployments — on different clouds, with different databases — share the same workflow engine, the same compliance logic, and the same guarantees. The cloud is a build-time choice, not a fork in our logic.

Recommended