Payments
How I Designed a Payment Gateway Orchestrator After an iGaming Course
PayMaestro: one API in front of many payment gateways, with routing, cascading retries, database-enforced idempotency, and fraud screening informed by an iGaming Academy anti-fraud certification.
After finishing the Anti-Fraud and Payments Handling course from the iGaming Academy, I wanted to actually build something with what it taught rather than let it sit as a certificate. That turned into PayMaestro: a payment orchestration API, built spec-first over a weekend.
What orchestration actually means
Payment orchestration is the layer above the payment gateways. A merchant integrates once, several acquiring routes sit behind that single integration, and the orchestration platform adds the intelligence: picking the best route for each transaction, retrying safely when a route fails, and keeping evidence of everything that happened.
That last part matters more in this domain than in most. Payment records are regulatory evidence, so the design has to treat them that way from the start.
Hard declines and soft declines are not the same failure
This is the distinction the course made concrete for me, and it's the core of the cascade policy. A soft decline, insufficient funds or a timeout, is recoverable, so it makes sense to try the next gateway in the route. A hard decline means a stolen or blocked card, and retrying that on another acquirer isn't resilience, it's just trying to push a fraudulent transaction through somewhere else.
So the cascade stops immediately on a hard decline and continues on a soft one:
if (result.ResultType is GatewayResultType.Approved)
{
payment.Authorize();
payment.Capture();
return;
}
if (result.ResultType is GatewayResultType.HardDecline)
break; // fraud signal: never retry elsewhere
// SoftDecline / Error -> next gateway in the route
Fraud screening before any gateway is contacted
Every fraud rule implements a single Domain contract, so adding one doesn't touch the orchestrator:
public interface IFraudRule
{
string RuleName { get; }
Task<FraudVerdict> EvaluateAsync(Payment payment, CancellationToken ct = default);
}
The first live rule is decline velocity: a card with three or more declined attempts in 24 hours is the classic card-testing pattern, so the fourth attempt is rejected with zero gateway calls. Screening runs before any gateway is contacted, which means a known-bad card costs nothing to reject.
Idempotency belongs in the database, not just the code
If a merchant retries a request, the same payment must never be charged twice. The obvious approach is checking whether the idempotency key exists before inserting, but that has a race window between the read and the write, and two concurrent retries can both pass the check.
So the idempotency key has a unique index on it. The database serialises the two inserts, the losing one throws, and that exception is caught and answered by replaying the stored result. The guarantee lives where concurrency is actually resolved instead of in application code that's hoping to win a race.
Routing as configuration
Gateway eligibility, which currencies each supports and what amount caps apply, plus route priority, all live in appsettings.json and get bound through the options pattern. Adding an acquirer is one class implementing the gateway contract and one config entry, with no changes to any business logic.
Where clean architecture actually earned its keep
This is the project where the layering I'd been learning about stopped being theoretical. API → Application → Domain ← Infrastructure, with the Domain holding the entities, the payment state machine, and all the contracts, and having zero external references. Infrastructure implements those contracts with EF Core and SQLite.
The payment state machine is guarded inside the aggregate itself, so an invalid transition throws a domain exception rather than silently corrupting a record. There's no path back out of Captured. My earlier projects used an anemic model where the entity was just a bag of properties and the rules lived somewhere else. Moving the invariants into the aggregate was a deliberate change from that.
Audit-first, and PCI-aware
Every gateway attempt is persisted with the gateway name, the order it was tried in, the result code, and how long it took. Every fraud flag is stored too, and deletes are restricted at the database level. If an acquirer raises a request for information, a single GET returns the full evidence pack.
Only the BIN and the last four digits of a card are ever stored, never the full number, which is the baseline for handling card data at all.
What I'd add next
More rules on the same IFraudRule contract: geo mismatch between the IP country and the card country, which needs real BIN and GeoIP data to be meaningful, and amount anomaly detection. Then refunds, and CI/CD to Azure Container Apps.