Jul 13, 2026
14 min read

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:
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.
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 | 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.
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:
@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:
<!-- 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.
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.
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:
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:
Both deployments deliver the same guarantee: both writes commit, or neither does. The workflow engine never learns which cloud it's on.
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:
// 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.
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:
// 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:
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.
Envelope encryption, selective fields, DEK caching — all of this is identical in both environments. Only the KMS provider behind the curtain changes.
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:
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.
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.

5 min read

5 min read

5 min read