A REST API for a fantasy soccer manager written in Go.
Current project status:
- user signup and login with JWT access tokens;
- automatic creation of one team per user;
- generation of an initial squad of 20 players;
- viewing and updating the current user's team and players;
- transfer market: list player, cancel listing, buy player;
- error localization in English and Georgian via
Accept-Language.
- Go
- Chi
- Bun + pgdriver
- PostgreSQL
- Goose
- JWT (
golang-jwt/jwt/v5) - bcrypt
log/slog
POST /auth/signupPOST /auth/loginGET /me/teamPATCH /me/teamGET /me/playersGET /me/players/{playerId}PATCH /me/players/{playerId}GET /market/playersPOST /market/players/{playerId}/listDELETE /market/players/{playerId}/listPOST /market/listings/{listingId}/buyGET /health
The repository includes a ready-to-use collection:
postman/Soccer Team API.postman_collection.json
The collection covers:
- all current endpoints;
- the main happy path;
- basic error scenarios.
Recommended happy path order in Postman:
Health CheckSignup SellerGet Seller TeamGet Seller PlayersGet Seller PlayerUpdate Seller TeamUpdate Seller PlayerCreate Listing as SellerList Public MarketSignup BuyerGet Buyer TeamGet Buyer PlayersBuy Listing as Buyer
After that, you can run the error scenarios from the Auth, Me, and Market folders.
The collection uses collection variables and automatically stores:
sellerAccessTokenbuyerAccessTokensellerPlayerIdsellerTeamCountryIdsellerPlayerCountryIdlistingId
To run the project in the full Docker scenario you only need:
- Docker
- Docker Compose
For local development without Docker for the API, you additionally need:
- Go
goose is not required on the host machine. Migrations run inside a dedicated migrate container.
To reduce Docker image size, goose in that container is built with PostgreSQL support only.
Copy the template:
cp .env.example .envBy default, .env.example already contains working local values for Docker Compose.
make upThis command does everything needed to verify the project:
- starts PostgreSQL;
- builds the Docker image for the API;
- runs the
migratecontainer and applies all migrations; - starts the
apicontainer.
After startup, the API is available at:
http://localhost:8080
make logsmake help # list available commands
make up # build images and start postgres + migrate + api
make down # stop containers
make logs # follow postgres, migrate, and api logs
make migrate-up # apply migrations inside the migrate container
make migrate-down # roll back the last migration inside the migrate container
make migrate-status # show migration status inside the migrate container
make migrate-create # create a new SQL migration file locally
make run # run the API locally without Docker
make test # run testsThis is the recommended scenario:
cp .env.example .env
make upThis mode is convenient for development if you want to run the API locally but keep PostgreSQL in Docker:
cp .env.example .env
docker compose up -d postgres
make migrate-up
make runIf the full Docker setup was already started before that, stop the api container first so that port 8080 is not occupied twice:
docker compose stop apiFor local runs, the application reads configuration from .env.
In the full Docker scenario, values are passed into containers through compose.yaml.
| Variable | Purpose | Default value |
|---|---|---|
APP_ENV |
application environment: local, test, prod |
local |
LOG_LEVEL |
log level | info |
HTTP_HOST |
HTTP server host | 0.0.0.0 |
HTTP_PORT |
HTTP server port | 8080 |
DB_HOST |
PostgreSQL host | localhost |
DB_PORT |
PostgreSQL port | 5432 |
DB_NAME |
database name | soccer_team_api |
DB_USER |
database user | postgres |
DB_PASSWORD |
database password | postgres |
DB_SSLMODE |
PostgreSQL SSL mode | disable |
JWT_ACCESS_SECRET |
access token secret | change-me-access-secret |
JWT_ACCESS_TTL |
access token TTL | 15m |
AUTH_PASSWORD_MIN_LEN |
minimum password length in characters | 5 |
AUTH_PASSWORD_MAX_LEN |
maximum password length in bytes | 72 |
DEFAULT_LOCALE |
fallback locale | en |
In the full Docker scenario, compose.yaml automatically overrides DB_HOST=postgres and DB_PORT=5432 inside the api and migrate containers.
Additional HTTP and DB timeout variables:
HTTP_READ_TIMEOUTHTTP_WRITE_TIMEOUTHTTP_IDLE_TIMEOUTHTTP_SHUTDOWN_TIMEOUTDB_CONNECT_TIMEOUT
After a successful signup, the system automatically creates:
- 1 team;
- 20 players;
- initial team budget:
5_000_000; - initial market value of each player:
1_000_000.
Initial squad composition:
- 3 goalkeepers
- 6 defenders
- 6 midfielders
- 5 attackers
A user can update only their own data:
- team:
name,country_id; - player:
first_name,last_name,country_id.
When a team is fetched, the API also returns:
total_players_market_value- the sum ofmarket_valuefor all players on the team.
The following fields cannot be changed through the API:
team_idpositionagemarket_value
The supported flow is:
- a team owner lists a player on the market with an
asking_price; - the market shows only active listings;
- the owner can cancel the listing;
- the purchase is executed in a transaction;
- the buyer's budget decreases;
- the seller's budget increases;
- the player moves to the new team;
- the player's
market_valueincreases by a random percentage from10%to100%; - the listing is closed with status
sold; - a transfer record is written to the
transferstable.
Errors are localized using the Accept-Language header.
Supported languages:
enka
If the language is not supported or not provided, the fallback is en.
Examples:
Accept-Language: en
Accept-Language: ka
Accept-Language: ka-GE,ka;q=0.9,en;q=0.8The code field in error responses is not localized and remains stable.
All error responses use this format:
{
"code": "invalid_request",
"message": "Invalid request body."
}Example error codes:
invalid_requestinvalid_emailinvalid_passwordinvalid_credentialsinvalid_access_tokeninvalid_team_nameinvalid_playerinvalid_countryinvalid_player_idinvalid_listing_idinvalid_asking_priceplayer_not_ownedlisting_already_activecannot_buy_own_playerinsufficient_budgetteam_not_foundplayer_not_foundlisting_not_foundinternal_error
| Method | Path | Description |
|---|---|---|
GET |
/health |
health check |
POST |
/auth/signup |
signup |
POST |
/auth/login |
login |
GET |
/market/players |
list active listings |
All protected endpoints require this header:
Authorization: Bearer <access_token>| Method | Path | Description |
|---|---|---|
GET |
/me/team |
get current team |
PATCH |
/me/team |
update current team |
GET |
/me/players |
list current team players |
GET |
/me/players/{playerId} |
get a player from the current team |
PATCH |
/me/players/{playerId} |
update a player from the current team |
POST |
/market/players/{playerId}/list |
list your player on the market |
DELETE |
/market/players/{playerId}/list |
cancel your player's listing |
POST |
/market/listings/{listingId}/buy |
buy a player |
- Register user A via
/auth/signup. - Get the access token.
- Check
/me/teamand/me/players. - List one of user A's players on the market.
- Register user B.
- Under user B's token, call
/market/players. - Buy the listing via
/market/listings/{listingId}/buy. - Check
/me/teamand/me/playersagain for both teams. - Verify that after the purchase:
- the buyer and seller budgets changed;
- the player moved to the new team;
total_players_market_valuewas recalculated.
Run all tests:
make testThe project currently includes unit and handler tests for:
- auth
- me endpoints
- transfer market
- i18n
- config validation
The project contains a few decisions that are reasonable for a take-home assignment, but would typically be expanded in a production system.
The current implementation uses only JWT access tokens, without a refresh token flow.
For a real project, it would make sense to add:
- refresh tokens;
- token revocation;
- refresh token rotation;
- logout / session management;
- storage and audit of active sessions.
I intentionally did not add that here to keep the auth flow focused and avoid expanding the scope beyond the assignment.
DTOs are not used everywhere: in some places HTTP handlers work directly with domain/model structures.
In a real project, a stricter DTO layer would usually be introduced:
- separate request/response models;
- explicit separation between domain and HTTP contracts;
- independent API evolution without coupling to internal models.
For this take-home task, I kept the implementation more direct to avoid unnecessary boilerplate.
The project includes a configurable logger, but it does not have full audit/debug logging for every service-layer action.
In a production setup, this would usually be extended with:
- structured logs for key business operations;
- correlation/request IDs;
- audit logging for sensitive actions;
- metrics and tracing.
Here I kept logging intentionally basic because the main goal of the assignment is to demonstrate API behavior, transaction handling, and project structure.
In all of these areas, I deliberately aimed for a balance between engineering quality and the scope of the take-home assignment:
- implement the full working functionality;
- keep the architecture readable;
- avoid production over-engineering where it is not required by the assignment.
cmd/api # HTTP API entry point
docker # scripts for Docker containers
internal/api # handlers, router, response helpers
internal/api/middleware # auth middleware
internal/auth # JWT provider and auth context helpers
internal/config # config loading and validation
internal/db # bun bootstrap and tx manager
internal/domain # bun/domain models
internal/i18n # localizer and embedded catalogs
internal/repository # database access
internal/service # business logic
migrations # SQL migrations
postman # Postman collection for manual API verification
compose.yaml # Docker Compose for postgres, migrate, and api
Dockerfile # build for the API and migration container
Makefile # commands for local development and Docker flow