Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
c63d3db
refactor(http): extract Swagger UI to dedicated module
mohammad-farrokhnia Jun 9, 2026
bb6e279
ci: enable automated CI/CD pipeline with database migrations
mohammad-farrokhnia Jun 9, 2026
19ce52d
refactor(audit): migrate to outbox pattern for reliable event delivery
mohammad-farrokhnia Jun 9, 2026
dc1c917
feat(migrations): add audit outbox table with transactional outbox pa…
mohammad-farrokhnia Jun 9, 2026
21d1f91
feat(audit): implement outbox worker for reliable event delivery
mohammad-farrokhnia Jun 9, 2026
bfd7b83
feat(i18n): add internationalization and error response formatting
mohammad-farrokhnia Jun 9, 2026
251bf95
feat(ledger): add UUID validation for account and transaction IDs
mohammad-farrokhnia Jun 9, 2026
5cf1014
test(ledger): add comprehensive service unit tests with mock store
mohammad-farrokhnia Jun 9, 2026
e43da58
feat(http): integrate Swagger UI for OpenAPI documentation
mohammad-farrokhnia Jun 13, 2026
1a18d3a
feat(grpc): add structured error logging for handler methods
mohammad-farrokhnia Jun 13, 2026
55da91d
fix: add //go:embed directive to swaggerJSON, add NotFound to classif…
mohammad-farrokhnia Jun 15, 2026
e6ea21c
feat(metrics): initialize transaction metrics with failure categories
mohammad-farrokhnia Jun 16, 2026
6b2e794
fix: add graphify-out directory to .gitignore
mohammad-farrokhnia Jun 16, 2026
8f638d2
fix: update README for accuracy and add API documentation details
mohammad-farrokhnia Jun 17, 2026
81f9f22
fix: update CI workflows and add golangci-lint configuration
mohammad-farrokhnia Jun 17, 2026
aea5e37
fix: replace static idempotency keys with dynamic key generation in t…
mohammad-farrokhnia Jun 17, 2026
4172838
fix: update CI workflow to use Go 1.26 and enhance protoc plugin inst…
mohammad-farrokhnia Jun 17, 2026
971a6e6
fix: update CI workflows to use latest buf release and streamline art…
mohammad-farrokhnia Jun 17, 2026
7d7c444
fix: update proto files for consistency and streamline HTTP option sy…
mohammad-farrokhnia Jun 17, 2026
f96d2b5
fix: disable formatting in CI workflow for buf action and clean up pr…
mohammad-farrokhnia Jun 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions .github/buf.gen.yaml

This file was deleted.

65 changes: 58 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,26 +1,62 @@
name: CI

on:
workflow_dispatch: # manual trigger only — re-add push/pull_request when team grows
pull_request:
branches: [master, develop]
push:
branches: [master, develop]

jobs:
generate:
name: Generate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.26'
cache: true
- name: Install protoc plugins
run: |
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway@latest
go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2@latest
- name: Install buf
run: |
curl -sSL "https://github.com/bufbuild/buf/releases/latest/download/buf-Linux-x86_64" \
-o /usr/local/bin/buf
chmod +x /usr/local/bin/buf
- run: buf generate
- uses: actions/upload-artifact@v4
with:
name: generated-proto
path: api/proto/ledger/v1/
retention-days: 1

lint:
name: Lint
runs-on: ubuntu-latest
needs: generate
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: generated-proto
path: api/proto/ledger/v1
- uses: actions/setup-go@v5
with:
go-version: '1.26'
cache: true
- uses: golangci/golangci-lint-action@v8
- uses: golangci/golangci-lint-action@v7
with:
version: v1.59.1
version: v2.1.6
install-mode: goinstall

test:
name: Test
runs-on: ubuntu-latest
needs: lint
needs: generate
services:
postgres:
image: postgres:16-alpine
Expand All @@ -37,21 +73,36 @@ jobs:
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: generated-proto
path: api/proto/ledger/v1
- uses: actions/setup-go@v5
with:
go-version: '1.26'
cache: true
- run: go mod download
- run: go test -race -count=1 -timeout 120s ./...
- name: Install migrate
run: |
curl -sSL https://github.com/golang-migrate/migrate/releases/download/v4.17.1/migrate.linux-amd64.tar.gz | tar xz
sudo mv migrate /usr/local/bin/migrate
- name: Run migrations
run: migrate -path migrations -database "postgres://postgres:postgres@localhost:5432/ledger_test?sslmode=disable" up
- name: Run tests
run: go test -race -count=1 -timeout 120s ./...
env:
DB_DSN: postgres://postgres:postgres@localhost:5432/ledger_test?sslmode=disable
DB_DSN_TEST: postgres://postgres:postgres@localhost:5432/ledger_test?sslmode=disable

build:
name: Build
runs-on: ubuntu-latest
needs: test
needs: [lint, test]
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: generated-proto
path: api/proto/ledger/v1
- uses: actions/setup-go@v5
with:
go-version: '1.26'
Expand Down
21 changes: 17 additions & 4 deletions .github/workflows/proto.yml
Original file line number Diff line number Diff line change
@@ -1,17 +1,30 @@
name: Proto

on:
workflow_dispatch: # manual trigger only
pull_request:
paths:
- 'api/proto/**'
- 'buf.yaml'
- 'buf.gen.yaml'
push:
branches: [master, develop]
paths:
- 'api/proto/**'
- 'buf.yaml'
- 'buf.gen.yaml'
workflow_dispatch:

jobs:
lint:
name: Buf lint
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: bufbuild/buf-action@v1
with:
version: '1.34.0'
lint: true
format: true
breaking: false
format: false
breaking: ${{ github.event_name == 'pull_request' }}
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,4 @@ configs/config.yaml
# Editor/IDE
.idea/
.vscode/
graphify-out/
File renamed without changes.
11 changes: 1 addition & 10 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ export
migrate migrate-down migrate-create \
gen help

# Development

## lint: run golangci-lint
lint:
golangci-lint run ./...
Expand All @@ -26,9 +24,7 @@ build:

## run: run the service locally
run:
go run ./cmd/ledger

# Infrastructure
-go run ./cmd/ledger

## up: start containers
up:
Expand All @@ -52,7 +48,6 @@ logs:
db:
docker exec -it ledger-postgres psql -U postgres -d ledger

# Migrations

## migrate: run all pending migrations (up)
migrate:
Expand All @@ -72,16 +67,12 @@ migrate-test:
migrate-down-test:
migrate -path migrations -database "$(DB_DSN_TEST)" down 1

# Code Generation

## gen: generate Go code and Swagger from .proto files
gen:
buf generate
cp api/openapi/ledger.swagger.json internal/transport/http/swagger.json

# ============================================================
# Help
# ============================================================

## help: print this help message
help:
Expand Down
65 changes: 47 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ graph TD

B --> G[ledger.Service]
G -->|Store interface| H[postgres.Store]
G -->|Auditor interface| I[audit.WebhookAuditor]
G -->|OutboxStore interface| I[audit.Worker]
H -->|SELECT FOR UPDATE| J[(PostgreSQL 16)]
I -->|POST /ingest| K[go-ingestor]
I -->|polls outbox, POST /ingest| K[go-ingestor]
```

### Why hexagonal architecture
Expand All @@ -29,8 +29,8 @@ go-ledger has three input transports: gRPC, HTTP Gateway, and a future event con
```
cmd/ledger (composition root)
internal/ledger (domain: types + Store port + Auditor port + Service)
↓ implements Store ↓ implements Auditor
internal/ledger (domain: types + Store port + OutboxStore port + Service)
↓ implements Store ↓ implements OutboxStore
internal/store/postgres internal/audit
PostgreSQL
Expand Down Expand Up @@ -86,6 +86,14 @@ Server → sees ON CONFLICT DO NOTHING, returns original transaction
Money → moved exactly once
```

## API Documentation

Swagger UI is available at `http://localhost:8080/swagger/` when the service is running. The raw OpenAPI spec is at `http://localhost:8080/swagger.json`.

## i18n

Error messages are translated based on the client's `Accept-Language` header. Supported languages: **English** (default) and **Farsi**. Every error response includes a `messageCode` field (e.g. `INSUFFICIENT_FUNDS`) that clients can use to render their own localized strings independently of the server-side translations.

## Tech Stack

| Layer | Technology |
Expand All @@ -99,31 +107,32 @@ Money → moved exactly once
| Logging | `log/slog` (JSON) |
| Metrics | Prometheus |
| Config | koanf |
| i18n | Built-in (English + Farsi) |

## Quick Start

**Prerequisites:** Go 1.26+, Docker, `buf`, `golang-migrate`, `grpcurl`

```bash
# Clone and set up environment
# Clone and configure
git clone https://github.com/mohammad-farrokhnia/go-ledger.git
cd go-ledger
cp .env.example .env
cp configs/config.example.yaml configs/config.yaml
# Edit configs/config.yaml — at minimum set database.dsn

# Start Postgres
make up

# Run migrations
# Run migrations (requires DB_DSN env var or set in .env)
make migrate

# Start the service
make run
```

The service starts three ports:
The service starts two ports:
- `:9090` — gRPC
- `:8080` — HTTP gateway + Swagger UI (`/swagger/`) + health (`/healthz`) + metrics (`/metrics`)
- `:8080` — HTTP gateway + Swagger UI (`/swagger/`) + health (`/health`) + metrics (`/metrics`)

## API Examples

Expand Down Expand Up @@ -179,14 +188,22 @@ grpcurl -plaintext -d '{
### Health check

```bash
curl http://localhost:8080/health
# {"status":"ok"}
curl http://localhost:8080/health | jq .
# {
# "data": {"status": "ok", "version": "1.0.0", "app": "go-ledger", "checks": {"postgres": "ok"}},
# "meta": {"appName": "go-ledger", "version": "1.0.0", "timestamp": "...", "messageCode": "HEALTH_OK", "message": "healthy"}
# }
```

### Prometheus metrics

```bash
curl http://localhost:8080/metrics | grep ledger
# ledger_transactions_total{status="success",error_type=""}
# ledger_transactions_total{status="fail",error_type="insufficient_funds"}
# ledger_transaction_duration_seconds_*
# ledger_db_errors_total
# ledger_grpc_active_connections
```

## Developer Commands
Expand All @@ -207,18 +224,23 @@ make help # list all targets

## Configuration

Copy `.env.example` → `.env`. Never commit `.env`.
The service loads `configs/config.yaml` first, then overlays any matching environment variables. Create your config by copying the template:

```bash
cp configs/config.example.yaml configs/config.yaml
```

The Makefile (`make migrate`, `make db`) reads a `.env` file for convenience — create one with `DB_DSN=...` if needed. Never commit `.env`.

| Variable | Default | Description |
|---|---|---|
| `DB_DSN` | required | Postgres connection string |
| `GRPC_PORT` | `9090` | gRPC server port |
| `HTTP_PORT` | `8080` | HTTP gateway port |
| `METRICS_PORT` | `9091` | Prometheus metrics port |
| `HTTP_PORT` | `8080` | HTTP gateway port (also serves `/metrics`, `/health`, `/swagger/`) |
| `AUDIT_MODE` | `async` | `sync` blocks, `async` fire-and-forget |
| `AUDIT_HOOK_URL` | — | go-ingestor endpoint, disabled if empty |
| `LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error` |
| `COMPOSE_PROJECT_NAME` | `docker` | the group name of continer in docker |
| `COMPOSE_PROJECT_NAME` | `docker` | Docker Compose project name |

## Running Tests

Expand Down Expand Up @@ -251,10 +273,12 @@ docker compose -f deployments/docker/docker-compose.yml up
| `account not found` | `NOT_FOUND` | Account ID does not exist |
| `transaction not found` | `NOT_FOUND` | Transaction ID does not exist |
| `insufficient funds` | `FAILED_PRECONDITION` | Sender balance too low |
| `currency mismatch` | `INVALID_ARGUMENT` | Accounts have different currencies |
| `currency mismatch between accounts` | `INVALID_ARGUMENT` | Accounts have different currencies |
| `transaction with this idempotency key already exists` | `ALREADY_EXISTS` | Duplicate — fetch original |
| `amount must be greater than zero` | `INVALID_ARGUMENT` | Invalid amount |
| `source and destination accounts must be different` | `INVALID_ARGUMENT` | Same account |
| `invalid account type` | `INVALID_ARGUMENT` | Account type is not USER or SYSTEM |
| `currency code must be a 3-letter ISO 4217 code` | `INVALID_ARGUMENT` | Malformed currency code |
| `an internal error occurred` | `INTERNAL` | Server error (details logged server-side) |

## Project Structure
Expand All @@ -264,13 +288,18 @@ api/proto/ledger/v1/ proto definition + generated Go code
cmd/ledger/ entrypoint — composition root only
configs/ config.yaml template
deployments/docker/ Dockerfile + docker-compose
docs/ API documentation
internal/
audit/ Auditor implementations (NoOp, Webhook)
audit/ outbox worker — polls DB, POSTs events to webhook
config/ koanf config loader
i18n/ error message translation (English + Farsi)
ledger/ domain: types, errors, Store/Auditor ports, Service
metrics/ Prometheus metric definitions
store/postgres/ Postgres implementation of ledger.Store
transport/grpc/ gRPC server, handler, interceptors, mappers
transport/http/ gRPC-Gateway, health check, Swagger UI
transport/http/ gRPC-Gateway, health check, Swagger UI, error handler
migrations/ SQL migration files (up + down)
pkg/
apperr/ structured application error type
response/ HTTP response envelope helpers
```
11 changes: 3 additions & 8 deletions api/proto/ledger/v1/ledger.proto
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,11 @@ syntax = "proto3";

package ledger.v1;

option go_package = "github.com/mohammad-farrokhnia/go-ledger/api/proto/ledger/v1;ledgerv1";

import "google/api/annotations.proto";
import "google/protobuf/timestamp.proto";
import "protoc-gen-openapiv2/options/annotations.proto";

option go_package = "github.com/mohammad-farrokhnia/go-ledger/api/proto/ledger/v1;ledgerv1";
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = {
info: {
title: "go-ledger API"
Expand Down Expand Up @@ -116,9 +115,7 @@ service LedgerService {
}

rpc GetBalance(GetBalanceRequest) returns (GetBalanceResponse) {
option (google.api.http) = {
get: "/v1/wallets/{wallet_id}/balance"
};
option (google.api.http) = {get: "/v1/wallets/{wallet_id}/balance"};
}

rpc CreateTransaction(CreateTransactionRequest) returns (CreateTransactionResponse) {
Expand All @@ -129,8 +126,6 @@ service LedgerService {
}

rpc GetWalletHistory(GetWalletHistoryRequest) returns (GetWalletHistoryResponse) {
option (google.api.http) = {
get: "/v1/wallets/{wallet_id}/history"
};
option (google.api.http) = {get: "/v1/wallets/{wallet_id}/history"};
}
}
Loading
Loading