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.
- Items have a rarity:
common/raresell 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.
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/Allwith reflection-derived columns); writes and locking queries are explicit. Query functions take onlyctx; the executor (pool or transaction) is carried on the context bydatabase.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 callsoracle.RefreshOnceandmarket.SettleDue.cmd/migrate— the migration tool.
See docs/ADR.md for the decisions and trade-offs behind this.
Create your local config from the example first (config.yml is gitignored):
cp config.example.yml config.yml./runner.sh # builds, starts postgres + app, migrates, serves on :8080runner.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.
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 settlementThe server and worker are independent processes; run each in its own terminal.
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: migrationsUses 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 pairInteractive 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).
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 DBDeliberate 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_keystable exists but noIdempotency-Keyhandling is wired. Some operations are naturally idempotent (settle, open-auction, and buy all reject duplicates), but a retriedplace bidwould 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. chargemints gold directly.POST /guilds/:id/chargeappends agrantledger 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.
PlaceBidlocks 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_secondsis 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.
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.
| 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.