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 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:
<!-- 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.
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:
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:
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.
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.
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 – 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 |
@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:
// 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:
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 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:
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 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 |
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.
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