Skip to main content

Architecture: 3-Layer Structure

Why separate into layers?

“Can’t we just put everything in one file?” — that’s faster when vibe coding. But in a financial system like QTG:
  1. What if an exchange API changes? → Only touch infrastructure (no changes to domain/application)
  2. Want to test without running real withdrawals? → Swap infrastructure with a fake
  3. Want to add a new bridge (deBridge)? → Just add a new executor to infrastructure
This is the essence of Dependency Inversion. Expensive rules (domain) know nothing about cheap implementation details (infrastructure).

3-Layer Architecture: The company org chart analogy

Mapping QTG to a company structure:

Role of each layer

Inbound HMAC authentication runs in the interfaces layer. This layer’s responsibility is to transform requests into trusted internal commands and hand them off to application — not to make business decisions.
Interfaces and Infrastructure are “adapters that attach to application.” Interfaces are inbound adapters (external → internal), Infrastructure are outbound adapters (internal → external). Application acts as the hub.

Dependency rules: who can import whom?

Concrete dependency rules

  • Domain has no external dependencies. It is pure business logic — no DB, no HTTP, no framework imports.
  • Infrastructure may reference domain types (states, execution context, protocols) but never reaches “up” into application orchestration.
  • Application is the only layer that combines both: it follows domain rules and drives infrastructure tools (executors, persistence) to carry them out.
  • Interfaces delegate to application services only. They never reach directly into persistence or other infrastructure internals.
QTG v3 follows a practical 3-layer direction. Rather than a separate abstract-port layer, application directly receives infrastructure models and sessions in a Light DI structure. It is not strict hexagonal architecture, but the core dependency direction is respected.

Why does domain know nothing about infrastructure?

Consider this analogy. The constitution (domain) says:
“A withdrawal request state can only transition in this order: PENDING_APPROVAL → APPROVED → EXECUTING → COMPLETED.”
This rule does not change regardless of whether the Upbit API changes, PostgreSQL gets replaced by MongoDB, or callbacks are sent to Slack. That is exactly why domain should know nothing about such implementation details. Dependencies must always flow in only one direction: things that change often → things that rarely change. If this is reversed, you get situations like “changing one Upbit API call breaks domain code.”

Bootstrap sequence: what happens when the app starts?

When the server starts, initialization proceeds in this order:

What runtime bootstrap does

Each lane is configuration-gated: CCIP only loads when MG_CCIP_ENABLED is set, Gateway only when its API base URL is configured, USDT0 only when MG_USDT0_ENABLED is set. Pro lanes (such as Stargate) load only through the single sanctioned boundary between the Free core and the Pro package — Free code never reaches into the Pro package directly. In a CEX-only deployment none of the bridge code is registered.

Each layer in detail

Domain Layer — “These are the laws of physics”

Domain holds business rules only. It knows absolutely nothing about DB or HTTP. The domain layer owns four kinds of rules:
The executor contract is defined as a structural-typing protocol. This is similar to Java’s interface, except implementations don’t even need to import domain. They just need to match the method signatures. This is true dependency inversion.

Application Layer — “I only give directions”

Application follows domain rules and composes infrastructure tools to create business flows.
State advance is the central hub. Nearly every service changes state through this one path. This embodies Single Responsibility — “state transitions and event recording happen in one place.” Every time state changes, a movement event is automatically recorded, so the audit trail is always complete.

Infrastructure Layer — “I do the actual work”

Infrastructure handles real I/O. Every point of contact with the outside world — DB queries, HTTP calls, file reads — lives here.
Open-Core executor boundary. QTG ships as Open Core. The Free package registers all of the lanes above at startup — CEX (Upbit, Binance, Bybit, Coinbase, OKX), CCTP, CCIP, EVM erc20, Gateway, USDT0/LayerZero, and Hyperliquid topup. Pro lanes (the Bithumb adapter and the Stargate bridge) are part of the separate Pro package and are registered through a single sanctioned boundary. Free code never reaches into the Pro package directly — that one graceful loader is the only door, and the boundary is mechanically enforced.

Interfaces Layer — “I’m the doorman”

Interfaces should be as thin as possible — receive the request, delegate to the application service, format the result, and return it.
Workers contain no business logic. Each worker does nothing more than call the matching application service on a loop — node_dispatcher, for example, just dispatches ready nodes. Business logic in application, loop execution in interfaces — this separation makes testing easy. You can test the underlying service directly, without a running worker.

Worker loop pattern

All workers share the same loop-runner utility: There are 8 always-on workers (started whenever MG_WORKERS_ENABLED is set) plus 9 conditional sidecars that start only when their feature flag is on:
evm_nonce_reaper and ccip_stranded_reaper, when enabled, join the core fail-fast group alongside the always-on workers; the remaining conditional workers run as independent sidecars. Workers run for the process-selected network class only (MG_NETWORK_MODE), and seed their heartbeats for that one. Serving mainnet and testnet means two separate deployments.
Errors inside a worker’s loop are caught and backed off exponentially, so a loop never wedges. A worker that escapes its loop is a different case: a sidecar is logged and the rest keep running, while a core worker tears down the whole worker set. There is no automatic restart — the process must be restarted.
For the design rationale — why workers poll Postgres instead of being driven by an external scheduler like Airflow, how row-level locking lets you run safe worker replicas, how each node carries its own next-observe deadline instead of needing a retry queue, and how the core-vs-sidecar tiering works — see Worker Orchestration.

Configuration

All runtime configuration comes from environment variables. Every variable uses the MG_ prefix, values can also be supplied via a .env file, and unknown variables are ignored.
The runtime is network-class split and process-scoped. PostgreSQL holds a public schema (api clients, nonce registry, global alembic version) plus the selected network schema (mainnet or testnet) with its own alembic head. MG_NETWORK_MODE selects the one network class this process serves; database sessions, registry sync, heartbeat seeding, and workers use that network. At startup the server rejects non-PostgreSQL URLs and validates public plus the selected schema and heads before accepting traffic; it does not require or query the unselected bucket. Fresh databases run the global Alembic chain followed by the selected network_chain@head — schema is never auto-created from ORM metadata. The in-memory engine registry can still represent both NetworkClass values without both physical schemas being present.

Summary: the value of this architecture