Back to Community Blog

One Codebase, Two Clouds

authorImage

Mounika Gampa

Jul 13, 2026

14 min read

featuredImage

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.

Dimension Deployment A (AWS) Deployment B (GCP)
Database DynamoDB — NoSQL key–value Cloud Spanner — Relational SQL
Transactions TransactWriteItems (item-level) Read-write transactions (multi-row)
Query language PartiQL / proprietary Standard SQL
Distribution Regional, partition-based Globally distributed
Client SDK AWS 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.

Dimension Deployment A Deployment B
Cloud AWS Google Cloud
Database DynamoDB Cloud Spanner
Transaction API TransactWriteItems Read-write transactions (SQL)
Key management AWS KMS Google Cloud KMS
Idempotency TTL Item-level TTL attribute Row deletion policy on TIMESTAMP
Business logic Identical — shared codebase Identical — 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

Feature Deployment A (AWS) Deployment B (GCP)
Checkpoint state machine Shared

Same logic, same transitions

Shared

Same logic, same transitions

Audit event recording Shared

Transactional outbox pattern

Shared

Transactional outbox pattern

Atomic multi-write Per-deployment

TransactWriteItems (DynamoDB)

Per-deployment

Read-write transaction (Spanner)

Field-level encryption Per-deployment

AWS KMS — envelope DEK/KEK

Per-deployment

Cloud KMS — envelope DEK/KEK

Idempotency key TTL Per-deployment

Item-level TTL attribute

Per-deployment

Row deletion policy on TIMESTAMP

Compliance rules Shared

Same policy engine

Shared

Same policy engine

Dirty tracking Shared

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 · Repository.java (illustrative)
@Repository
public interface Repository {

    // Direct methods — execute immediately against the database.
    Entity save(Entity entity);
    Entity update(Entity entity);
    List<Entity> findByUserId(String userId);
    Entity lookupById(String userId, String id);

    // Deferred methods — return a *description* of a pending write.
    // The caller decides when and how to commit.
    DeferredOperation saveDeferred(...);
    DeferredOperation updateDeferred(...);
}

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

XML · Build configuration (illustrative)
<!-- Deployment A: Provider A artifact — zero Provider B imports on classpath -->
<profile>
  <id>provider-a</id>
  <activation><activeByDefault>true</activeByDefault></activation>
  <dependency><artifactId>database-provider-a</artifactId></dependency>
</profile>

<!-- Deployment B: Provider B artifact — zero Provider A imports on classpath -->
<profile>
  <id>provider-b</id>
  <dependency><artifactId>database-provider-b</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
TransactionContext txn = transactionContext.start();

// Describe the state change. Not executed yet.
DeferredOperation saveRecord =
    repository.saveDeferred(record);

// Describe the audit event. Also not executed yet.
DeferredOperation recordEvent =
    eventPublisher.createDeferredEvent(EVENT_TYPE, 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 · EventPublisher.java (illustrative)
// Best-effort: immediate publish, not atomic with a state change.
eventPublisher.publish(event);

// Transactional outbox: deferred — commits in the SAME transaction
// as the entity write. Works identically on both deployments.
DeferredOperation op = eventPublisher.publishDeferred(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 KMS Deployment B — Cloud KMS
Injected via @Profile("aws") Injected via @Profile("gcp")

– KmsClient from AWS SDK v2

– GenerateDataKey + Encrypt API

– Regional endpoint per market

– KeyManagementServiceClient (GCP)

– Encrypt / Decrypt RPC API

– Key ring scoped per project

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 · Entity.java — transparent encryption (illustrative)
// Callers never touch a key or call KMS directly.
public void setSensitiveField(Map<String, String> values) {
    Map<String, String> encrypted = encryptField(values);
    record.setSensitiveField(encrypted);
}

public Map<String, String> getSensitiveField() {
    return decryptField(record.getSensitiveField());
}

⚡ Cache the decrypted DEKs — in both deployments

Decrypted keys are cached briefly to reduce repeated KMS calls, with safeguards limiting how long key material stays in memory.

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 safeguards are applied consistently across both deployments, with only minor differences in how keys expire at the data-model level:

Idempotency check

Look up the key; if found, replay the stored response. Otherwise process the request and store the key with its response for replay on retry.

Deployment A — DynamoDB TTL Deployment B — Spanner TTL
Native item-level expiry Row 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

The idempotency mechanism behaves identically in both deployments; only the underlying storage details differ.

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