Aussie EcoLens writes to AWS and reads from Azure. Not because a client asked for it, and not because it's cheaper — it isn't. The constraint was set for us, and working inside it taught me more about consistency boundaries than any single-cloud version of the same system would have.
What CQRS forces you to admit
The moment the read side lives in another vendor's network, you can't hand-wave about eventual consistency any more. Every projection has a measurable lag, and you have to decide, per feature, whether that lag is acceptable. Ours was held to one to two seconds by a replicator riding DynamoDB Streams — but even one to two seconds forces the question. The honest answer was that almost none of the read paths needed to be current. An observation that appears in the public feed a second or two after upload is fine. A user who uploads an image and doesn't immediately see it in their own list is not.
So that one exception got special handling and everything else got the cheap projected path. That split — one narrow strongly-consistent case, everything else eventually-consistent — is the whole design.
The write side
Uploads hit API Gateway, land in S3, and fire a Lambda. Detection runs first, classification second, and the result is written to DynamoDB before a Streams event goes out to the Azure projector that builds the Cosmos DB read model.
client → API Gateway → S3 (raw)
↓
Lambda: MegaDetector v5a (is there an animal?)
↓ (if yes)
Lambda: SpeciesNet (which animal?)
↓
DynamoDB → Streams → Azure projector → Cosmos DB (read model)Running the cheap model first is the single highest-leverage decision in the pipeline. A camera trap produces overwhelmingly empty frames — wind on a branch, a passing shadow. MegaDetector v5a answers one binary question quickly, and only what survives that filter pays for SpeciesNet classification. On our 26-image ground-truth set the classifier hit 100% top-1, but that number only means anything because the detector kept junk frames out of it.
The whole write side — four Lambdas for tagging, thumbnails, replication and SNS alerts — is provisioned as code through Terraform and AWS SAM. That mattered more than it sounds: when the seam between clouds is the risky part, you want the AWS half to be reproducible down to the IAM policy so it isn't also a variable.
Where it hurt
- Two IAM models. AWS roles and Azure RBAC do not think about identity the same way, and the mapping between them is manual.
- Two sets of logs, in two formats, in two consoles. Correlating a single request across the seam needs a trace ID you propagate yourself, because nothing does it for you.
- Egress. Data leaving one cloud to be projected into another is billed, and that bill is the standing argument against ever doing this without a reason.
Would I do it again
For a product, no — not without a regulatory or commercial reason forcing it. For learning where a system's consistency boundaries genuinely are, it was the fastest teacher I've had, because the architecture stopped being a diagram and became something with a latency number attached to it.