Sponsored Content
Skip to content

Repository files navigation

Dragon Market

Backend for a safe marketplace (limit orders + auctions) in the world of Aethoria, written in Go on PostgreSQL. The market must stay correct and auditable under duplicate operations, flaky external services, and concurrent bids.

Mechanics

  • Items have a rarity: common/rare sell via fixed-price limit orders, legendary (one of a kind) sells via auction.
  • Guilds have a gold wallet and a daily purchase cap.
  • Auctions: one active per legendary item, time-windowed with a 5-minute anti-snipe extension. A bid reserves funds (not deducts); the highest bid wins at settlement, losers are released. No bids returns the item to the shelf.
  • Limit orders: a guild lists a common/rare item at a fixed price; a buyer with enough available balance buys it, subject to the daily cap.
  • Wallets are derived from an append-only ledger, never stored: available = total - reserved. Every movement is traceable.

Architecture

api     ->  market  ->  models  ->  database
                            (queries colocated with each entity)
worker  ->  oracle + market.SettleDue  ->  models  ->  database

The HTTP API and the background jobs are two separate processes that share the database. cmd/server only serves requests; cmd/worker runs the price-oracle refresh and the auction-settlement sweep on tickers. Splitting them means a stuck or failing job can't take down the API, and either can be restarted or scaled on its own.

  • src/api — Gin handlers and routing.
  • src/market — transactional business operations that enforce the invariants (PlaceBid, CancelBid, OpenAuction, SettleAuction, ListForSale, BuyItem).
  • src/models — entities and their SQL. Reads use a generic layer (database.Find/All with reflection-derived columns); writes and locking queries are explicit. Query functions take only ctx; the executor (pool or transaction) is carried on the context by database.Tx.
  • src/database — sqlx/lib/pq connection guarded by a gobreaker circuit breaker.
  • src/oracle — the external Price Oracle behind an interface, with a flaky mock and a tolerant base-price updater (RefreshOnce).
  • cmd/server — the HTTP API binary.
  • cmd/worker — the background jobs binary; owns the tickers and calls oracle.RefreshOnce and market.SettleDue.
  • cmd/migrate — the migration tool.

See docs/ADR.md for the decisions and trade-offs behind this.

Running

Create your local config from the example first (config.yml is gitignored):

cp config.example.yml config.yml

Docker (everything)

./runner.sh            # builds, starts postgres + app, migrates, serves on :8080

runner.sh sets CONFIG_FILE and runs docker compose up --build. It starts postgres, the app service (mounts the config, points the database host at the postgres service, runs migrations, serves on :8080), and the worker service (the background jobs). Override the config with CONFIG_FILE=./other.yml ./runner.sh.

Local

Needs a Postgres on localhost:5432 (e.g. docker compose up -d postgres).

go run ./cmd/migrate up     # apply migrations
go run ./cmd/server         # serve on :8080
go run ./cmd/worker         # background oracle refresh + auction settlement

The server and worker are independent processes; run each in its own terminal.

Configuration

A single config.yml (YAML), selected with -c (default config.yml):

server:     { address: ":8080" }
oracle:     { interval_seconds: 30 }
settlement: { interval_seconds: 60 }
database:
  url: postgres://dragon:dragon@localhost:5432/dragon_market?sslmode=disable
  migrations: migrations

Migrations

Uses golang-migrate. Plain SQL files in migrations/, numbered and paired (0001_init.up.sql / .down.sql); applied versions are tracked in schema_migrations.

go run ./cmd/migrate up            # apply pending
go run ./cmd/migrate down [N|all]  # roll back N (default 1)
go run ./cmd/migrate version       # current version
go run ./cmd/migrate new <name>    # scaffold the next pair

API

Interactive docs (Swagger UI) are served at http://localhost:8080/docs, backed by the OpenAPI spec in docs/swagger.yml (also served raw at /swagger.yml). It is the easiest way to exercise the endpoints by hand.

Method Path Body / query
GET /health
GET /docs Swagger UI
GET /guilds ?page=&limit= (paginated)
POST /guilds {name, daily_purchase_cap}
POST /guilds/:id/charge {amount, note?} (grants gold)
GET /guilds/:id/wallet
POST /items {name, rarity, owner_guild_id}
GET /items ?page=&limit= (paginated)
GET /items/:id
POST /items/:id/listing {seller_guild_id, price} (common/rare)
POST /items/:id/buy {buyer_guild_id}
POST /items/:id/auction {seller_guild_id, duration_seconds} (legendary)
POST /items/:id/bid {bidder_guild_id, amount}
DELETE /items/:id/bid/:bid_id ?guild_id=
GET /auctions ?page=&limit= (active, paginated)
GET /auctions/:id
POST /auctions/:id/settle manual settle (the worker does this automatically)

List endpoints (/guilds, /items, /auctions) return a paginated envelope — {data, total, page, limit, total_pages} — ordered by created_at. page is 1-based (default 1); limit defaults to 20 and is capped at 100.

Fund a wallet with POST /guilds/:id/charge, which appends a grant ledger entry. It mints gold with no payment integration or auth (see Known limitations).

Testing

End-to-end (Ginkgo/Gomega) hitting the real router against a dragon_market_test database that is created, migrated, and dropped by the suite.

go test ./tests/...                                   # needs local Postgres
TEST_DATABASE_URL=postgres://... go test ./tests/...  # override target DB

Known limitations

Deliberate scope cuts for this challenge, acknowledged rather than hidden. With more time these are the first things to address (see docs/ADR.md):

  • No idempotency middleware (TODO). The idempotency_keys table exists but no Idempotency-Key handling is wired. Some operations are naturally idempotent (settle, open-auction, and buy all reject duplicates), but a retried place bid would create a second bid and reservation. This is the main gap against the "duplicate operations" requirement.
  • No authentication / authorization. The acting guild is passed in the request body or ?guild_id= query, and is trusted as-is. Anyone who knows a guild id can act as that guild (bid, cancel, list, buy). A real system would derive the guild from an authenticated session/token and authorize the action.
  • charge mints gold directly. POST /guilds/:id/charge appends a grant ledger entry with no payment provider, no auth, and no audit of where the money came from. It exists so wallets can be funded for testing. In a real system, funding would come from a settled external payment and sit behind auth.
  • Bids on one auction serialize. PlaceBid locks the auction row (FOR UPDATE) for the whole transaction, so concurrent bids on the same auction run one at a time. This is a deliberate consistency-over-throughput trade-off: it guarantees the strict ≥5% increment and a sound funds check. It is per-row, so different auctions still run in parallel, but a single very hot auction is a hotspot. Left as-is (a shorter critical section or a single-statement conditional insert would be the first optimization).
  • Daily cap on purchases only. Enforced on limit-order buys, not on auction bids or wins.
  • Fixed default auction length. When duration_seconds is omitted or zero, an auction runs for 24h (DefaultAuctionDuration). There is no per-item or configurable default, no minimum, and no maximum.
  • No metrics / tracing, and wallets are summed per read rather than cached. List pagination is plain offset paging (fine here; cursor paging would scale better for very deep pages).

These are accepted trade-offs for a sample; the core invariants (no double-sell, funds/cap checks, ledger auditability, concurrency-safe bidding) are implemented and tested.

Database choice

The hard requirements are money invariants under concurrency: no double-sell, no spending past available = total - reserved, the daily cap, one active auction per legendary item. That demands a relational, transactional (ACID) database, so the realistic options are SQLite, MySQL, or PostgreSQL.

SQLite is out: it serializes all writers behind one database-level lock and has no real row-level locking, but we rely on SELECT ... FOR UPDATE to lock a single wallet/auction row while validating and committing. Between MySQL and Postgres, Postgres wins on what this domain leans on: partial unique indexes (one active auction/listing per item is a one-line constraint), CHECK constraints, ENUMs, exact BIGINT money, FOR UPDATE SKIP LOCKED, and transactional DDL so a failed migration rolls back fully. The hardest invariants are enforced by the database, not just application code.

Schema

Table Purpose
guilds Players: name + daily purchase cap (no balance column).
items Tradable items with rarity and status.
listings Fixed-price limit orders.
auctions Auctions; one active per item, time-windowed.
bids Bids on auctions.
wallet_ledger Append-only money log; balances are derived from it.
idempotency_keys Reserved for retry de-duplication (see ADR).
schema_migrations Applied migration versions.

Authoritative definitions in migrations/0001_init.up.sql.

About

small dragon items market in dragon age

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages