A Learning Portfolio Project Demonstrating Enterprise Elixir/Phoenix Patterns
What I Learned โข Quick Demo โข Architecture โข Quick Start โข API Documentation
This project started as "build a simple banking API" and evolved into a deep dive on professional Elixir architecture. Here's what makes this portfolio piece unique:
Latest Enhancements (January 2025):
- โ JWT Verification Bypass Fixed - Critical security vulnerability resolved
- โ JWT Configuration Unification - Standardized issuer, audience, and expiry across environments
- โ JWT Secret Standardization - Enforced 64+ character secrets with validation
- โ JWT Config-Driven Approach - Removed hardcoded values, made claims configurable
- โ Password Hashing Decoupling - Removed Mix.env() dependencies for production flexibility
- โ Oban Configuration Consolidation - Unified queue management across environments
- โ CI Database Readiness - Hardened Postgres health checks with proper dependencies
- โ OpenTelemetry Optimization - Disabled unnecessary instrumentation overhead
- โ CI Quality Gates - Added format checking and warnings-as-errors enforcement
- โ Docker Compose Health Dependencies - Proper service startup ordering
- โ Database Index Audit - Added 20+ optimized indexes for pagination and queries
- โ
Policy Combinators - Complex authorization logic with
all/1,any/1,negate/1 - โ Controller Helper Macros - Standardized CRUD, pagination, and batch operations
- โ Queryable Extensions - Advanced filtering, sorting, and aggregation capabilities
- โ
Cache Standardization - TTL helpers and
get_or_putvariants for consistent caching
- โ WorkerBehavior Enhancement - Custom retry logic, pre/post-work hooks, telemetry
- โ OpenTelemetry Resource Alignment - Environment-driven resource attributes
- โ Redis Adapter Consistency - Cleaned up stale references, prepared for distributed caching
- โ JWT Token Tradeoffs Documentation - Comprehensive analysis of security vs performance
- โ Configuration Precedence - Clear documentation of JWT and Oban configuration
- โ Problem Registry Audit - Verified RFC 9457 compliance and error discovery
- โ Documentation Clarity - Marked pseudo-code examples to prevent copy-paste confusion
| Pattern | What I Built | Why It Matters |
|---|---|---|
| ๐๏ธ Behaviors for DRY Code | WorkerBehavior, ServiceBehavior, CacheAdapter, Queryable |
Eliminated 280+ lines of boilerplate across workers. Shows I understand abstraction vs premature optimization. |
| ๐ฏ Error Catalog System | 40+ error reasons โ 8 categories โ HTTP codes + retry policies | Most APIs have inconsistent errors. Mine has a single source of truth that drives retry logic, telemetry, and responses. |
| ๐ Security by Design | Constant-time auth, JWT rotation, RBAC with policies, audit logging | Banking-grade security: timing attack prevention, token revocation, role-based permissions. |
| ๐งช Test Quality | 3,796-line integration test, edge cases (null bytes, timing), performance tests | I test like companies should: integration flows, security vulnerabilities, and performance regression. |
| ๐ Background Jobs Done Right | Oban + error-aware retry, priority queues, dead letter queue | Workers know which errors to retry (external API failures) vs fail fast (business rules). |
| ๐ Data Access Patterns | Keyset pagination, query behaviors, ETS caching | Shows I care about performance at scale beyond CRUD. |
I learned when to abstract and when not to:
โ Abstracted because 4+ schemas needed it:
SchemaHelpersโ 220 lines of validation duplication removedQueryableโ Consistent filtering/sorting across all resourcesWorkerBehaviorโ Standard telemetry/logging for all workersCacheAdapterโ Switch ETS โ Redis with zero code changes
โ Didn't abstract because it would hurt clarity:
- Controllers - Each has unique validation/authorization
- Policies - Domain-specific rules don't generalize well
- Migrations - Database changes need explicit audit trail
Lesson: Abstraction is a trade-off. I chose clarity first, then DRY where duplication was painful (4+ instances).
When asked about this project, I highlight:
"I built a banking API to learn production Elixir patterns. The interesting parts:"
Error Catalog System - Instead of scattered
{:error, "some string"}everywhere, I built an error taxonomy with 8 categories that drives HTTP codes, retry policies, and telemetry. This meant one change to add circuit breaking for all external API calls.Behaviors for Scale - When I noticed 180 lines of identical code in two Oban workers, I created
WorkerBehavior. Now adding a new worker is 30 lines vs 200, and all workers get telemetry for free.Security Depth - I implemented constant-time authentication after reading about timing attacks. It's a 10-line change that prevents email enumeration via response time analysis.
Testing Strategy - My integration test is 3,796 lines that validates entire user flows: register โ login โ create payment โ process payment. I also test edge cases like null byte injection and concurrent balance updates.
Interactive API Documentation: https://your-app.onrender.com/api/docs
Health Check: https://your-app.onrender.com/api/health
Try it now:
curl https://your-app.onrender.com/api/healthWant to run it locally? Here's a 2-minute setup:
# Clone and setup
git clone https://github.com/rafaelRojasVi/ledger-bank-api.git
cd ledger-bank-api
# One-command setup (requires Docker)
./test_setup.sh
# Start server
mix phx.serverVisit http://localhost:4000/api/docs to see:
- ๐ OpenAPI/Swagger UI with "Try it out" buttons
- ๐ JWT authentication built into the UI
- ๐ Request/response examples for every endpoint
- ๐งช Test endpoints directly from your browser
# 1. Health check (no auth required)
curl http://localhost:4000/api/health
# 2. Login (get JWT token)
curl -X POST http://localhost:4000/api/auth/login \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"password": "password123"
}'
# Response includes access_token - copy it for next requests
# 3. Get user profile (requires auth)
curl http://localhost:4000/api/auth/me \
-H "Authorization: Bearer <your_access_token>"
# 4. View user statistics (admin only)
curl http://localhost:4000/api/users/stats \
-H "Authorization: Bearer <admin_token>"Default credentials after seeding:
- Regular User:
[email protected]/password123 - Admin User:
[email protected]/adminpassword123456
LedgerBank API is a learning project that implements production-grade financial services patterns in Elixir/Phoenix. It demonstrates clean architecture, sophisticated error handling, security best practices, and background job processingโall the patterns you'd find in a real fintech company.
I built this to answer: "How would I architect a complex Elixir API if I had to do it from scratch?"
The result:
- ๐๏ธ Clean Architecture - Behaviors, services, policies, and pure functions
- ๐ Security First - JWT rotation, constant-time auth, RBAC, audit logs
- ๐ฏ Error Excellence - Error catalog with retry policies and circuit breakers
- ๐ Production Patterns - Docker, CI/CD, health checks, monitoring
- ๐ Domain-Driven Design - Financial and accounts contexts with clear boundaries
- โก Performance - Keyset pagination, ETS caching, query optimization
- โ JWT-based authentication with access and refresh tokens
- โ Token rotation for enhanced security
- โ Role-based access control (User, Admin, Support)
- โ Constant-time authentication to prevent timing attacks
- โ Secure password hashing with Argon2
- โ Role-based password complexity (8 chars for users, 15 for admins)
- ๐ฐ Multi-bank integration via OAuth2
- ๐ณ Account management with balance tracking
- ๐ Transaction history with advanced filtering
- ๐ธ Payment processing with business rule validation
- ๐ Bank synchronization workers for automated updates
- ๐ฆ Multi-currency support
- ๐ OAuth2 client for external bank APIs
- ๐ Token refresh mechanisms
- ๐ Transaction sync from external sources
- ๐ฆ Multi-institution support
- โก Real-time balance updates
- ๐ Offset pagination (traditional page/page_size)
- ๐ Keyset pagination (cursor-based for better performance)
- ๐ Advanced filtering by status, role, date, amount
- ๐ Multi-field sorting with direction control
- ๐ User statistics and financial health metrics
- ๐พ ETS-based caching with TTL support
- ๐ฏ Canonical error structs across all layers
- ๐ Error categorization (validation, authentication, business rules, etc.)
- ๐ Automatic retry logic for transient failures
- ๐ Circuit breaker pattern for external services
- ๐ Error telemetry and correlation IDs
- ๐ฅ Health check endpoints
- ๐ RFC 9457 Problem Details compliance with
application/problem+json - ๐ Problem Type Registry at
/api/problemsfor error discovery - โฑ๏ธ Retry-After headers for retryable errors
- ๐ง Oban integration for reliable job processing
- ๐ณ Payment processing workers with priority queues
- ๐ Bank sync workers with rate limiting
- ๐ Job retry strategies based on error categories
- ๐ Dead letter queue handling
- โฐ Scheduled jobs support
- ๐ OpenAPI/Swagger documentation
- ๐ณ Docker & Docker Compose support
- ๐งช Comprehensive test suite (1000+ tests)
- ๐ Phoenix LiveDashboard for monitoring
- ๐ Structured logging with correlation IDs
- ๐ Security audit logging
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ 1. HTTP Request โ POST /api/auth/login โ
โโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โโโโโโโโโโโผโโโโโโโโโโ
โ Router + Plugs โ โ SecurityHeaders, RateLimit, SecurityAudit
โโโโโโโโโโโฌโโโโโโโโโโ Adds correlation ID, checks rate limits
โ
โโโโโโโโโโโผโโโโโโโโโโโ
โ AuthController โ โ InputValidator validates email/password
โโโโโโโโโโโฌโโโโโโโโโโโ Thin HTTP layer, delegates immediately
โ
โโโโโโโโโโโผโโโโโโโโโโโ
โ AuthService โ โ Pure business logic, no HTTP concerns
โ (login_user) โ Uses Token.generate_*, UserService.authenticate_user
โโโโโโโโโโโฌโโโโโโโโโโโ
โ
โโโโโโโโโโโผโโโโโโโโโโโ
โ UserService โ โ authenticate_user: constant-time validation
โ (authenticate) โ Checks password BEFORE checking user status (security)
โโโโโโโโโโโฌโโโโโโโโโโโ
โ
โโโโโโโโโโโผโโโโโโโโโโโ
โ User Schema โ โ Ecto changeset validation
โ + Repo.get_by โ Database constraints enforced
โโโโโโโโโโโฌโโโโโโโโโโโ
โ
โโโโโโโโโโโผโโโโโโโโโโโ
โ PostgreSQL โ โ ACID transactions, unique constraints
โโโโโโโโโโโโโโโโโโโโโโ
โ
โโโโโโโโโโโผโโโโโโโโโโโ
โ Response: JWT โ โ ErrorAdapter maps Error struct โ HTTP 200/400/401
โ + User object โ Adds correlation ID to response
โโโโโโโโโโโโโโโโโโโโโโ
Key Insight: Each layer has one responsibility and errors bubble up as Error structs, not strings.
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Web Layer โ
โ Controllers โข Plugs โข Validators โข Adapters โข Router โ
โ โ
โ โ
HTTP concerns only (status codes, headers, JSON) โ
โ โ
InputValidator converts params โ validated data โ
โ โ
ErrorAdapter converts Error structs โ HTTP responses โ
โโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โโโโโโโโโโโโโโโโโโโโโโโโโโดโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Business Layer โ
โ โ
โ โโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโ โ
โ โ Accounts Contextโ โ Financial Contextโ โ
โ โ โข UserService โ โ โข FinancialServ. โ โ
โ โ โข AuthService โ โ โข PaymentWorker โ โ
โ โ โข Token โ โ โข BankSyncWorker โ โ
โ โ โข Policy โ โ โข Policy โ โ
โ โ โข Normalize โ โ โข Normalize โ โ
โ โโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโ โ
โ โ
โ โ
Pure business logic, no HTTP knowledge โ
โ โ
Policy functions return true/false (easily testable) โ
โ โ
Normalize functions are pure (no DB, no side effects) โ
โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ Core Layer โ โ
โ โ โข Error Handling โข Error Catalog โ โ
โ โ โข Service Behavior โข Validator โ โ
โ โ โข Worker Behavior โข Cache Adapter โ โ
โ โ โข Schema Helpers โข Queryable โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ
โ โ
Shared infrastructure for all contexts โ
โ โ
Behaviors define contracts (swap implementations) โ
โ โ
Error catalog is single source of truth โ
โโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โโโโโโโโโโโโโโโโโโโโโโโโโโดโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Data Layer โ
โ Ecto Schemas โข Repo โข Migrations โข PostgreSQL โ
โ โ
โ โ
Database constraints enforced (CHECK, FOREIGN KEY) โ
โ โ
Changesets validate before hitting database โ
โ โ
Migrations are explicit and auditable โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
This project showcases practical experience with:
Backend Engineering:
- โ RESTful API design with OpenAPI/Swagger documentation
- โ Authentication & authorization (JWT, RBAC, OAuth2 simulation)
- โ Database design (PostgreSQL, Ecto migrations, indexes, constraints)
- โ Background job processing (Oban, priority queues, retry logic)
- โ Caching strategies (ETS, adapter pattern for Redis-ready scaling)
- โ Error handling (categorization, retry policies, circuit breakers)
Software Architecture:
- โ Clean architecture (web โ business โ data layer separation)
- โ Domain-driven design (bounded contexts: Accounts, Financial)
- โ Behavior-driven development (contracts via Elixir behaviors)
- โ Policy-driven authorization (pure functions, easily testable)
- โ Adapter pattern (swappable cache/bank clients)
Testing & Quality:
- โ Comprehensive test coverage (unit, integration, security, performance)
- โ Test-driven development (TDD) approach
- โ Property-based testing (StreamData)
- โ Mock/stub strategies (Mox, Mimic, Bypass)
- โ CI/CD pipeline (GitHub Actions)
DevOps & Operations:
- โ Containerization (Docker, Docker Compose)
- โ Health monitoring (liveness, readiness probes)
- โ Structured logging with correlation IDs
- โ Telemetry & observability
- โ Database migrations in production
Security:
- โ OWASP best practices (Argon2, CSRF, XSS prevention)
- โ Timing attack prevention
- โ Rate limiting & abuse prevention
- โ Security audit logging
- โ Input sanitization & validation
All services implement a common behavior for consistent error handling and database operations.
Before (Repeated in every service):
def get_user(id) do
case Repo.get(User, id) do
nil -> {:error, ErrorHandler.business_error(:user_not_found, %{id: id})}
user -> {:ok, user}
end
endAfter (ServiceBehavior provides):
@behaviour ServiceBehavior
def get_user(id) do
context = build_context(__MODULE__, :get_user, %{user_id: id})
ServiceBehavior.get_operation(User, id, :user_not_found, context)
endResult: Every service gets standard error handling, context building, and correlation IDs for free.
Instead of ad-hoc error handling, all errors go through a catalog that defines category, HTTP code, and retry behavior.
Code Example:
# In ErrorCatalog
def reason_codes do
%{
:insufficient_funds => :business_rule, # โ 422, no retry
:bank_api_error => :external_dependency, # โ 503, retry 3x
:invalid_email_format => :validation # โ 400, no retry
}
end
# In services, just use the reason:
{:error, ErrorHandler.business_error(:insufficient_funds, context)}
# The catalog automatically:
# - Maps to HTTP 422
# - Sets retryable: false
# - Adds correlation ID
# - Emits telemetryThe Flow:
Error Reason (:insufficient_funds)
โ
ErrorCatalog.category_for_reason() โ :business_rule
โ
ErrorCatalog.http_status_for_category() โ 422
โ
Error.should_retry?() โ false (business rules don't retry)
โ
WorkerBehavior sees should_retry?() = false โ Dead Letter Queue
Why This Matters: One change to the catalog affects all workers, services, and controllers. Adding circuit breaking took 5 minutes because the categories were already there.
Instead of mixing authorization with business logic, I separated all permission checks into Policy modules.
Benefits:
# Pure, easily testable (no DB, no mocks needed)
def can_update_user?(current_user, target_user, attrs) do
cond do
current_user.role == "admin" -> true
current_user.id == target_user.id -> can_update_self?(attrs)
current_user.role == "support" -> can_support_update_user?(attrs)
true -> false
end
end
# Test with zero setup:
test "users can update their own name but not role" do
user = %User{id: "123", role: "user"}
assert Policy.can_update_user?(user, user, %{full_name: "New Name"})
refute Policy.can_update_user?(user, user, %{role: "admin"})
endWhy: Permission rules change often. Keeping them in pure functions means:
- โ No database needed for tests
- โ Easy to audit (all rules in one file)
- โ Can be shared with frontend for UI authorization
Problem: Mixing data cleaning with business logic makes code hard to test.
Solution: Pure Normalize modules for all contexts.
# Input from HTTP
params = %{"email" => " [email protected] ", "role" => "AdMiN"}
# Normalize
normalized = Normalize.user_attrs(params)
# => %{"email" => "[email protected]", "role" => "user"} # Force role to "user" for security
# Now service just does business logic:
UserService.create_user(normalized)Security Win: Normalize.user_attrs/1 forces role to "user" for public registration. Admin creation uses Normalize.admin_user_attrs/1 which allows role selection but requires admin token.
Problem: Each Oban worker had 180+ lines of identical code (timing, logging, telemetry, retry decisions).
Solution: WorkerBehavior handles infrastructure, workers implement perform_work/2 only.
Before (PaymentWorker was 245 lines):
use Oban.Worker
def perform(%Oban.Job{args: args} = job) do
start_time = System.monotonic_time(:millisecond)
correlation_id = Error.generate_correlation_id()
context = %{worker: "PaymentWorker", job_id: job.id, ...}
Logger.info("Worker started", context)
case process_payment(args["payment_id"]) do
{:ok, result} ->
duration = System.monotonic_time(:millisecond) - start_time
Logger.info("Worker completed", ...)
emit_telemetry(:success, duration, ...)
:ok
{:error, error} ->
Logger.error("Worker failed", ...)
emit_telemetry(:failure, ...)
if Error.should_retry?(error), do: {:error, error}, else: :discard
end
end
defp emit_telemetry(...), do: ... # 30 more lines
defp log_error(...), do: ... # 20 more lines
# ... (repeat for BankSyncWorker)After (PaymentWorker is 80 lines):
use LedgerBankApi.Core.WorkerBehavior,
queue: :payments,
max_attempts: 5,
tags: ["payment"]
def worker_name, do: "PaymentWorker"
# WorkerBehavior handles timing, logging, telemetry, retry decisions
def perform_work(%{"payment_id" => id}, context) do
# Just business logic:
with {:ok, payment} <- get_payment(id),
{:ok, result} <- FinancialService.process_payment(id) do
{:ok, result}
end
endResult: Infrastructure is centralized. Adding NotificationWorker requires ~40 lines, not 200.
lib/
โโโ ledger_bank_api/
โ โโโ accounts/ # User & auth context
โ โ โโโ schemas/ # User, RefreshToken
โ โ โโโ user_service.ex # User business logic
โ โ โโโ auth_service.ex # Authentication logic
โ โ โโโ token.ex # JWT generation/validation
โ โ โโโ policy.ex # Permission rules
โ โ โโโ normalize.ex # Data transformation
โ โ
โ โโโ financial/ # Banking & payments context
โ โ โโโ schemas/ # Bank, Account, Payment, Transaction
โ โ โโโ integrations/ # External bank API clients
โ โ โโโ workers/ # Background job workers
โ โ โโโ financial_service.ex
โ โ โโโ policy.ex
โ โ โโโ normalize.ex
โ โ
โ โโโ core/ # Shared infrastructure
โ โ โโโ error.ex # Canonical error struct
โ โ โโโ error_catalog.ex # Error taxonomy
โ โ โโโ error_handler.ex # Error creation & handling
โ โ โโโ service_behavior.ex # Service pattern
โ โ โโโ validator.ex # Core validation logic
โ โ โโโ cache.ex # ETS caching
โ โ
โ โโโ application.ex # Supervision tree
โ โโโ repo.ex # Database repository
โ โโโ release.ex # Release tasks
โ
โโโ ledger_bank_api_web/
โโโ controllers/ # HTTP controllers
โโโ plugs/ # Authentication, authorization, rate limiting
โโโ adapters/ # Error adapter for HTTP responses
โโโ validation/ # Input validation
โโโ router.ex # Route definitions
โโโ endpoint.ex # HTTP endpoint
โโโ logger.ex # Structured logging
โโโ telemetry.ex # Metrics & monitoring
- Elixir 1.18+ - Functional, concurrent language
- Phoenix 1.7+ - Web framework
- Ecto 3.11+ - Database wrapper and query generator
- PostgreSQL 16+ - Primary database
- Joken - JWT token generation and validation with config-driven claims
- Argon2 - Password hashing (OWASP recommended) with environment-specific configuration
- CORS - Cross-origin resource sharing
- Security Headers - CSP, HSTS, X-Frame-Options, etc.
- Policy Combinators - Complex authorization logic with
all/1,any/1,negate/1
- Oban 2.17+ - Reliable background job processing
- Telemetry - Metrics and monitoring
- Phoenix.PubSub - Distributed messaging
- Req - Modern HTTP client for bank API integration
- Finch - HTTP client pool
- Jason - JSON encoding/decoding
- Swoosh - Email delivery
- ExUnit - Testing framework
- Mox - Mocking library
- Mimic - Another mocking option
- Bypass - HTTP mocking
- Phoenix LiveDashboard - Real-time monitoring
- Credo - Code analysis (optional)
- Docker & Docker Compose - Containerization
- GitHub Actions - CI/CD pipeline
Complete documentation is available in the docs/ folder:
- Getting Started - Setup and running instructions
- API Reference - Complete endpoint documentation
- Architecture Guide - System design and patterns
- Developer Guide - Code patterns and workflows
- Testing Guide - Testing strategies and examples
- Deployment Guide - Production deployment options
- Cheatsheet - Quick reference for developers
- Elixir 1.18+ and Erlang/OTP 26+
- PostgreSQL 16+ (via Docker or local installation)
- Docker and Docker Compose (recommended)
- Git
git clone https://github.com/rafaelRojasVi/ledger-bank-api.git
cd ledger-bank-apimix deps.getCreate a .env file (or export directly):
# Database Configuration
export DB_HOST=localhost
export DB_PORT=5432
export DB_USER=postgres
export DB_PASS=postgres
export DB_NAME=ledger_bank_api_dev
# JWT Secret (minimum 32 characters)
export JWT_SECRET="your-super-secret-jwt-key-at-least-32-chars-long-please"
# Phoenix Secret (generate with: mix phx.gen.secret)
export SECRET_KEY_BASE="your-phoenix-secret-key-base-here"
# External Bank API (optional)
export MONZO_CLIENT_ID="your-monzo-client-id"
export MONZO_CLIENT_SECRET="your-monzo-client-secret"docker-compose up -d dbWait for PostgreSQL to be ready:
# Check if PostgreSQL is running
docker-compose ps
# Or use pg_isready
pg_isready -h localhost -p 5432 -U postgres# Create database
mix ecto.create
# Run migrations
mix ecto.migrate
# Seed sample data
mix run priv/repo/seeds.exs# Interactive mode with Phoenix server
iex -S mix phx.server
# Or non-interactive mode
mix phx.serverThe API will be available at http://localhost:4000
Use the provided setup script for a complete environment:
./test_setup.shThis script:
- โ Starts Docker containers
- โ Drops and recreates databases
- โ Runs all migrations
- โ Seeds sample data
- โ Clears cache
# Health check
curl http://localhost:4000/api/health
# Expected response:
# {"status":"ok","timestamp":"2025-10-10T...", "version":"1.0.0","uptime":...}After seeding, you can login with:
Regular User:
- Email:
[email protected] - Password:
password123!
Admin User:
- Email:
[email protected] - Password:
admin123!
http://localhost:4000/api
Most endpoints require authentication via JWT Bearer token:
Authorization: Bearer <your_jwt_token>POST /api/auth/login
Content-Type: application/json
{
"email": "[email protected]",
"password": "password123!"
}Response:
{
"data": {
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"user": {
"id": "uuid",
"email": "[email protected]",
"full_name": "Alice Example",
"role": "user",
"status": "ACTIVE"
}
},
"success": true,
"timestamp": "2025-10-10T12:00:00Z"
}POST /api/auth/refresh
Content-Type: application/json
{
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}POST /api/auth/logout
Content-Type: application/json
{
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}GET /api/auth/me
Authorization: Bearer <access_token>GET /api/users?page=1&page_size=20&sort=email:asc&status=ACTIVE
Authorization: Bearer <admin_token>Query Parameters:
page- Page number (default: 1)page_size- Items per page (default: 20, max: 100)sort- Sort field and direction (e.g.,email:asc,created_at:desc)status- Filter by status (ACTIVE, SUSPENDED, DELETED)role- Filter by role (user, admin, support)
GET /api/users/keyset?limit=20&cursor={"inserted_at":"2024-01-01T00:00:00Z","id":"uuid"}
Authorization: Bearer <admin_token>GET /api/users/{id}
Authorization: Bearer <admin_token>POST /api/users
Content-Type: application/json
{
"email": "[email protected]",
"full_name": "New User",
"password": "password123!",
"password_confirmation": "password123!"
}Note: Role is automatically set to user for public registration.
POST /api/users/admin
Authorization: Bearer <admin_token>
Content-Type: application/json
{
"email": "[email protected]",
"full_name": "New Admin",
"password": "admin-password-123!",
"password_confirmation": "admin-password-123!",
"role": "admin"
}PUT /api/users/{id}
Authorization: Bearer <admin_token>
Content-Type: application/json
{
"full_name": "Updated Name",
"status": "ACTIVE"
}DELETE /api/users/{id}
Authorization: Bearer <admin_token>GET /api/users/stats
Authorization: Bearer <admin_token>Response:
{
"data": {
"total_users": 100,
"active_users": 85,
"admin_users": 5,
"suspended_users": 15
},
"success": true
}GET /api/profile
Authorization: Bearer <access_token>PUT /api/profile
Authorization: Bearer <access_token>
Content-Type: application/json
{
"full_name": "Updated Name"
}PUT /api/profile/password
Authorization: Bearer <access_token>
Content-Type: application/json
{
"current_password": "oldpassword",
"new_password": "newpassword123!",
"password_confirmation": "newpassword123!"
}POST /api/payments
Authorization: Bearer <access_token>
Content-Type: application/json
{
"amount": "100.50",
"direction": "DEBIT",
"payment_type": "PAYMENT",
"description": "Coffee shop payment",
"user_bank_account_id": "uuid"
}Payment Types:
TRANSFER- Bank transferPAYMENT- General paymentDEPOSIT- Deposit to accountWITHDRAWAL- Withdrawal from account
Directions:
CREDIT- Money coming inDEBIT- Money going out
GET /api/payments?page=1&page_size=20&direction=DEBIT&status=PENDING
Authorization: Bearer <access_token>GET /api/payments/{id}
Authorization: Bearer <access_token>POST /api/payments/{id}/process
Authorization: Bearer <access_token>GET /api/payments/{id}/status
Authorization: Bearer <access_token>DELETE /api/payments/{id}
Authorization: Bearer <access_token>POST /api/payments/validate
Authorization: Bearer <access_token>
Content-Type: application/json
{
"amount": "100.50",
"direction": "DEBIT",
"payment_type": "PAYMENT",
"description": "Test payment",
"user_bank_account_id": "uuid"
}Response:
{
"data": {
"valid": true,
"message": "Payment validation successful",
"payment": {...},
"account": {...}
}
}GET /api/payments/stats
Authorization: Bearer <access_token>GET /api/healthGET /api/health/detailedGET /api/problemsResponse:
{
"data": {
"problems": [
{
"code": "insufficient_funds",
"type": "https://api.ledgerbank.com/problems/insufficient_funds",
"status": 422,
"title": "Insufficient funds for this transaction",
"category": "business_rule",
"retryable": false
}
],
"categories": {
"validation": 12,
"business_rule": 8,
"external_dependency": 5
}
},
"success": true
}GET /api/problems/insufficient_fundsResponse:
{
"data": {
"code": "insufficient_funds",
"type": "https://api.ledgerbank.com/problems/insufficient_funds",
"status": 422,
"title": "Insufficient funds for this transaction",
"category": "business_rule",
"retryable": false,
"retry_delay_ms": 0,
"max_retry_attempts": 0,
"description": "Occurs when attempting to process a payment that exceeds the available account balance",
"examples": ["Payment amount: $150, Available balance: $100"]
},
"success": true
}GET /api/problems/category/business_ruleResponse:
{
"status": "ok",
"timestamp": "2025-10-10T12:00:00Z",
"version": "1.0.0",
"uptime": 123456,
"checks": {
"database": "ok",
"memory": "ok",
"disk": "ok"
}
}GET /api/health/readyGET /api/health/liveAll errors follow RFC 9457 Problem Details format:
{
"type": "https://api.ledgerbank.com/problems/email_already_exists",
"title": "Email already exists",
"status": 409,
"detail": "A user with this email address already exists",
"instance": "req_1234567890abcdef",
"code": "email_already_exists",
"reason": "email_already_exists",
"category": "conflict",
"retryable": false,
"timestamp": "2025-10-10T12:00:00Z",
"details": {
"field": "email"
}
}Content-Type: application/problem+json
Retry-After Header: Included for retryable errors (e.g., Retry-After: 1000)
Common Error Codes:
400- Validation error401- Authentication required / Invalid token403- Insufficient permissions404- Resource not found409- Conflict (e.g., duplicate email)422- Business rule violation429- Rate limit exceeded500- Internal server error503- Service unavailable
โโโโโโโโโโโโโโโ
โ Users โ
โโโโโโโโฌโโโโโโโ
โ
โ 1:N
โ
โโโโโโโโโโโโโโโโโโโโโโโโ
โ โ
โ โ
โโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโ
โRefreshTokens โ โUserBankLogins โ
โโโโโโโโโโโโโโโโ โโโโโโโโโโฌโโโโโโโโโโ
โ
โ 1:N
โ
โ
โโโโโโโโโโโโโโโโโโโโ
โUserBankAccounts โ
โโโโโโโโโโฌโโโโโโโโโโ
โ
โโโโโโโโโโโโโโผโโโโโโโโโโโโโโ
โ โ โ
โ 1:N โ 1:N โ 1:N
โ โ โ
โโโโโโโโโโโโโโโโ โโโโโโโโโโโโ โโโโโโโโโโโโโโโโ
โTransactions โ โUserPaymentsโ โBankBranches โ
โโโโโโโโโโโโโโโโ โโโโโโโโโโโโ โโโโโโโโโฌโโโโโโโ
โ
โ N:1
โ
โโโโโโโโโโโโโโโโ
โ Banks โ
โโโโโโโโโโโโโโโโ
Primary table for user authentication and profile data.
Column Type Description
--------------------------------------------------------------
id uuid Primary key
email string Unique email address
full_name string User's full name
status string ACTIVE | SUSPENDED | DELETED
role string user | admin | support
password_hash string Argon2 hashed password
active boolean Account active flag
verified boolean Email verified flag
suspended boolean Account suspended flag
deleted boolean Soft delete flag
inserted_at timestamp Creation timestamp
updated_at timestamp Last update timestamp
Indexes:
- UNIQUE: email
- INDEX: status, role, active, verified, suspended, deleted
- INDEX: inserted_at, updated_atFinancial institutions available for integration.
Column Type Description
--------------------------------------------------------------
id uuid Primary key
name string Bank name (unique)
country string 2-3 letter country code
code string Unique bank code
logo_url string Bank logo URL
api_endpoint string API endpoint URL
status string ACTIVE | INACTIVE
integration_module string Elixir module for integration
inserted_at timestamp Creation timestamp
updated_at timestamp Last update timestamp
Indexes:
- UNIQUE: name, code
- INDEX: country, statusPhysical or virtual branches of banks.
Column Type Description
--------------------------------------------------------------
id uuid Primary key
bank_id uuid Foreign key โ banks
name string Branch name
iban string IBAN code (unique)
country string 2-3 letter country code
routing_number string 9-digit routing number
swift_code string SWIFT/BIC code (unique)
inserted_at timestamp Creation timestamp
updated_at timestamp Last update timestamp
Indexes:
- UNIQUE: iban, swift_code
- INDEX: bank_id, country
- FOREIGN KEY: bank_id โ banks.id (ON DELETE CASCADE)OAuth2 credentials for user's bank connections.
Column Type Description
--------------------------------------------------------------
id uuid Primary key
user_id uuid Foreign key โ users
bank_branch_id uuid Foreign key โ bank_branches
username string Bank username
status string ACTIVE | INACTIVE | ERROR
last_sync_at timestamp Last synchronization time
sync_frequency integer Sync frequency in seconds (300-86400)
access_token text OAuth2 access token
refresh_token text OAuth2 refresh token
token_expires_at timestamp Token expiration time
scope string OAuth2 granted scopes
provider_user_id string User ID from bank provider
inserted_at timestamp Creation timestamp
updated_at timestamp Last update timestamp
Indexes:
- UNIQUE: (user_id, bank_branch_id, username)
- INDEX: user_id, bank_branch_id, status
- FOREIGN KEY: user_id โ users.id (ON DELETE CASCADE)
- FOREIGN KEY: bank_branch_id โ bank_branches.id (ON DELETE CASCADE)User's actual bank accounts at financial institutions.
Column Type Description
--------------------------------------------------------------
id uuid Primary key
user_bank_login_id uuid Foreign key โ user_bank_logins
user_id uuid Foreign key โ users
currency string 3-letter currency code (e.g., USD, EUR)
account_type string CHECKING | SAVINGS | CREDIT | INVESTMENT
balance decimal(15,2) Current account balance
last_four string Last 4 digits of account number
account_name string Custom account name
status string ACTIVE | INACTIVE | CLOSED
last_sync_at timestamp Last synchronization time
external_account_id string External account identifier
inserted_at timestamp Creation timestamp
updated_at timestamp Last update timestamp
Indexes:
- UNIQUE: external_account_id
- INDEX: user_bank_login_id, account_type, status
- FOREIGN KEY: user_bank_login_id โ user_bank_logins.id (ON DELETE CASCADE)
- FOREIGN KEY: user_id โ users.id (ON DELETE CASCADE)
Check Constraints:
- balance >= 0 OR account_type = 'CREDIT'User-initiated payments and transfers.
Column Type Description
--------------------------------------------------------------
id uuid Primary key
user_bank_account_id uuid Foreign key โ user_bank_accounts
user_id uuid Foreign key โ users
amount decimal(15,2) Payment amount (must be > 0)
direction string CREDIT | DEBIT
description string Payment description
payment_type string DEPOSIT | WITHDRAWAL | TRANSFER | PAYMENT
status string PENDING | PROCESSING | COMPLETED | FAILED | CANCELLED
posted_at timestamp When payment was posted
external_transaction_id string External transaction identifier
inserted_at timestamp Creation timestamp
updated_at timestamp Last update timestamp
Indexes:
- INDEX: user_bank_account_id, amount, payment_type, status, direction
- INDEX: posted_at, external_transaction_id
- FOREIGN KEY: user_bank_account_id โ user_bank_accounts.id (ON DELETE CASCADE)
- FOREIGN KEY: user_id โ users.id (ON DELETE CASCADE)
Check Constraints:
- amount > 0Completed transactions on user accounts.
Column Type Description
--------------------------------------------------------------
id uuid Primary key
account_id uuid Foreign key โ user_bank_accounts
user_id uuid Foreign key โ users
description string Transaction description
amount decimal(15,2) Transaction amount (must be > 0)
direction string CREDIT | DEBIT
posted_at timestamp When transaction was posted
inserted_at timestamp Creation timestamp
updated_at timestamp Last update timestamp
Indexes:
- INDEX: account_id, posted_at, amount, direction
- COMPOSITE INDEX: (account_id, posted_at)
- FOREIGN KEY: account_id โ user_bank_accounts.id (ON DELETE CASCADE)
- FOREIGN KEY: user_id โ users.id (ON DELETE CASCADE)
Check Constraints:
- amount > 0JWT refresh tokens for secure token rotation.
Column Type Description
--------------------------------------------------------------
id uuid Primary key
user_id uuid Foreign key โ users
jti string JWT token identifier (unique)
expires_at timestamp Token expiration time
revoked_at timestamp Token revocation time (null if active)
inserted_at timestamp Creation timestamp
updated_at timestamp Last update timestamp
Indexes:
- UNIQUE: jti
- INDEX: user_id, expires_at, revoked_at
- FOREIGN KEY: user_id โ users.id (ON DELETE CASCADE)Background job queue (managed by Oban).
See Oban documentation for schema details.
The application implements a three-layer error handling system:
- Error Catalog - Central taxonomy of all errors
- Error Struct - Canonical error representation
- Error Handler - Error creation and processing
:validation # Input validation failures โ 400
:not_found # Resource not found โ 404
:authentication # Authentication failures โ 401
:authorization # Authorization failures โ 403
:conflict # Resource conflicts โ 409
:business_rule # Business logic violations โ 422
:external_dependency# External service failures โ 503
:system # Internal system errors โ 500:invalid_amount_format
:missing_fields
:invalid_direction
:invalid_email_format
:invalid_password_format
:invalid_uuid_format
:invalid_datetime_format
:invalid_name_format
:invalid_role
:invalid_status
:invalid_payment_type
:invalid_currency_format
:invalid_account_type:user_not_found
:account_not_found
:payment_not_found
:token_not_found
:bank_not_found:invalid_credentials
:invalid_password
:invalid_token
:token_expired
:token_revoked
:invalid_token_type:forbidden
:insufficient_permissions
:unauthorized_access:email_already_exists
:already_processed
:duplicate_transaction:insufficient_funds
:account_inactive
:daily_limit_exceeded
:amount_exceeds_limit
:negative_amount
:negative_balance
:currency_mismatch
:account_frozen
:account_suspended:timeout
:service_unavailable
:bank_api_error
:payment_provider_error:internal_server_error
:database_error
:configuration_error| Category | Retryable | Circuit Breaker | Max Retries | Retry Delay |
|---|---|---|---|---|
| validation | โ No | โ No | 0 | 0ms |
| not_found | โ No | โ No | 0 | 0ms |
| authentication | โ No | โ No | 0 | 0ms |
| authorization | โ No | โ No | 0 | 0ms |
| conflict | โ No | โ No | 0 | 0ms |
| business_rule | โ No | โ No | 0 | 0ms |
| external_dependency | โ Yes | โ Yes | 3 | 1000ms |
| system | โ Yes | โ Yes | 2 | 500ms |
{
"error": {
"type": "unprocessable_entity",
"message": "Insufficient funds for this transaction",
"code": 422,
"reason": "insufficient_funds",
"details": {
"account_id": "uuid",
"available": "50.00",
"requested": "100.00"
},
"timestamp": "2025-10-10T12:00:00Z"
}
}queues: [
banking: 3, # Bank API calls (rate-limited)
payments: 2, # Payment processing (critical)
notifications: 3, # Email/SMS notifications
default: 1 # Miscellaneous tasks
]Processes user payments with comprehensive business rule validation.
Features:
- โ Comprehensive payment validation
- โ Balance updates with transactions
- โ Duplicate detection
- โ Priority queue support
- โ Intelligent retry based on error category
- โ Dead letter queue for non-retryable errors
Usage:
# Schedule payment processing
PaymentWorker.schedule_payment(payment_id)
# Schedule with priority (0-9, 0 = highest)
PaymentWorker.schedule_payment_with_priority(payment_id, 0)
# Schedule with delay
PaymentWorker.schedule_payment_with_delay(payment_id, 60)Synchronizes bank data from external APIs.
Features:
- โ OAuth2 token refresh
- โ Account balance synchronization
- โ Transaction history fetch
- โ Rate limiting respect
- โ Retry with exponential backoff
- โ Uniqueness constraints (5-minute window)
Usage:
# Schedule bank sync
BankSyncWorker.schedule_sync(login_id)
# Schedule with delay
BankSyncWorker.schedule_sync_with_delay(login_id, 300)Workers automatically determine retry behavior based on error categories:
# Business rule violations โ No retry, mark as failed
:insufficient_funds โ Dead Letter Queue
# External dependency errors โ Retry with backoff
:bank_api_error โ 3 retries with exponential backoff
# System errors โ Retry with shorter delay
:database_error โ 2 retries with 500ms base delay# Get job status
PaymentWorker.get_payment_job_status(payment_id)
# Cancel scheduled job
PaymentWorker.cancel_payment_job(payment_id)Monitor Oban jobs in real-time at:
http://localhost:4000/dev/dashboard/oban
- โ Access tokens (15-minute expiry)
- โ Refresh tokens (7-day expiry with rotation)
- โ Token revocation support
- โ JTI (JWT ID) for unique token identification
- โ Token blacklisting via database
- โ Argon2 hashing (OWASP recommended)
- โ
Role-based complexity:
- Regular users: minimum 8 characters
- Admin/Support: minimum 15 characters
- โ Password confirmation required
- โ Current password verification for changes
Prevents timing attacks for email enumeration:
# SECURITY: Always performs password hashing
# even for non-existent users
@dummy_password_hash Argon2.hash_pwd_salt("dummy_password")
def authenticate_user(email, password) do
user = get_user_by_email(email) || nil
password_hash = if user, do: user.password_hash, else: @dummy_password_hash
# Constant time comparison
password_valid? = Argon2.verify_pass(password, password_hash)
# Check user existence and status AFTER password verification
# to maintain constant time regardless of account state
endRoles:
โโโ user - Regular users
โโโ support - Customer support agents
โโโ admin - System administratorsPure functions for permission checks:
# User operations
Policy.can_update_user?(current_user, target_user, attrs)
Policy.can_delete_user?(current_user, target_user)
Policy.can_list_users?(current_user)
# Financial operations
Policy.can_create_payment?(current_user, payment_attrs)
Policy.can_process_payment?(current_user, payment)
Policy.can_view_account?(current_user, account)Automatically applied to all responses:
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 1; mode=block
Referrer-Policy: strict-origin-when-cross-origin
Content-Security-Policy: default-src 'none'; ...
Strict-Transport-Security: max-age=31536000; includeSubDomains (production only)
Configurable rate limiting to prevent abuse:
# Default configuration
max_requests: 100 per window
window_size: 60 seconds (1 minute)
# Rate limit headers in responses
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 85
X-RateLimit-Reset: 1633987200Comprehensive security event tracking:
# Logged security events
- Authentication failures
- Authorization failures
- Rate limit violations
- Suspicious request patterns
- Security policy violations
- Admin actions- Web Layer - InputValidator for HTTP inputs
- Core Layer - Core.Validator for data formats
- Schema Layer - Ecto changesets for database constraints
- Business Layer - Business rule validation
- โ UUID format validation
- โ Email format with null byte rejection
- โ SQL injection prevention via parameterized queries
- โ XSS prevention via output escaping
- โ CSRF protection for session-based auth
- โ Length limits on all string fields
-- Check constraints
ALTER TABLE users
ADD CONSTRAINT status_check
CHECK (status IN ('ACTIVE', 'SUSPENDED', 'DELETED'));
ALTER TABLE user_payments
ADD CONSTRAINT amount_positive_check
CHECK (amount > 0);
-- Foreign key cascades
ON DELETE CASCADE -- Automatic cleanup# Sensitive fields removed from logs
sanitized_fields = [
:password, :password_hash,
:access_token, :refresh_token,
:secret, :private_key, :api_key
]- โ Passwords โ Argon2 hashed
- โ OAuth tokens โ Encrypted at rest (recommended)
- โ JWT secrets โ Environment variables only
- โ API keys โ Never committed to git
The test suite is organized by domain and concern:
test/
โโโ ledger_bank_api/
โ โโโ accounts/ # ~2,500 lines of tests
โ โ โโโ auth_service_test.exs # Authentication logic
โ โ โโโ constant_time_auth_test.exs # Timing attack prevention
โ โ โโโ edge_case_test.exs # Edge cases & error handling
โ โ โโโ normalize_test.exs # Data transformation
โ โ โโโ policy_test.exs # Permission rules
โ โ โโโ user_service_test.exs # User business logic
โ โ โโโ user_service_keyset_test.exs # Keyset pagination
โ โ โโโ user_service_oban_test.exs # Background jobs
โ โ
โ โโโ core/ # ~700 lines of tests
โ โ โโโ cache_test.exs # Caching logic
โ โ โโโ error_catalog_financial_test.exs # Error system
โ โ
โ โโโ financial/ # ~3,000 lines of tests
โ โโโ financial_service_test.exs # Financial operations
โ โโโ financial_service_validation_test.exs
โ โโโ normalize_test.exs # Financial data transformation
โ โโโ payment_business_rules_test.exs # Payment validation
โ โโโ policy_test.exs # Financial permissions
โ โโโ workers/
โ โโโ bank_sync_worker_test.exs
โ โโโ payment_worker_test.exs
โ โโโ priority_execution_test.exs
โ
โโโ ledger_bank_api_web/ # ~6,000 lines of tests
โ โโโ controllers/ # Controller integration tests
โ โ โโโ auth_controller_test.exs
โ โ โโโ payments_controller_test.exs
โ โ โโโ users_controller_test.exs
โ โ โโโ profile_controller_test.exs
โ โ โโโ authorization_test.exs
โ โ โโโ security_user_creation_test.exs
โ โ โโโ users_controller_keyset_test.exs
โ โ
โ โโโ plugs/ # Plug tests
โ โ โโโ rate_limit_test.exs
โ โ โโโ security_headers_test.exs
โ โ
โ โโโ validation/ # Input validation tests
โ โโโ input_validator_test.exs
โ โโโ input_validator_financial_test.exs
โ
โโโ support/ # Test helpers & fixtures
โโโ conn_case.ex # Controller test setup
โโโ data_case.ex # Database test setup
โโโ oban_case.ex # Oban test setup
โโโ password_helper.ex # Test password utilities
โโโ fixtures/
โ โโโ users_fixtures.ex
โ โโโ banking_fixtures.ex
โโโ mocks/
โโโ financial_service_mock.ex
# Run all tests
mix test
# Run tests with coverage
mix test --cover
# Run specific test file
mix test test/ledger_bank_api/accounts/user_service_test.exs
# Run specific test
mix test test/ledger_bank_api/accounts/user_service_test.exs:42
# Run tests by pattern
mix test --only auth
# Run tests with warnings as errors (CI mode)
mix test --warnings-as-errors
# Run tests in parallel
mix test --max-cases 4- โ Service business logic
- โ Policy functions
- โ Normalization functions
- โ Validation functions
- โ Error handling
- โ Controller endpoints
- โ Database operations
- โ Authentication flow
- โ Authorization checks
- โ Background jobs
- โ Constant-time authentication
- โ Role-based access control
- โ Token validation
- โ Rate limiting
- โ Input validation
# Create test users
user = UsersFixtures.user_fixture()
admin = UsersFixtures.admin_fixture()
# Create banking data
bank = BankingFixtures.bank_fixture()
account = BankingFixtures.bank_account_fixture(user)# Mock financial service
Mimic.copy(LedgerBankApi.Financial.FinancialService)
Mimic.stub(FinancialService, :process_payment, fn id ->
{:ok, %{id: id, status: "COMPLETED"}}
end)GitHub Actions workflow (.github/workflows/ci.yml):
- โ
Elixir 1.18.4 / OTP 26.2
- โ
PostgreSQL 16 via Docker
- โ
Dependency caching
- โ
Database creation & migrations
- โ
Test suite execution with --warnings-as-errors
- โ
Docker image buildBefore deploying to production:
- Set strong
JWT_SECRET(minimum 64 characters) - Set secure
SECRET_KEY_BASE(generate withmix phx.gen.secret) - Configure
DATABASE_URLwith SSL - Set appropriate
POOL_SIZE(default: 10) - Configure external bank API credentials
- Set
MIX_ENV=prod - Set
PHX_SERVER=truefor standalone deployment - Configure logging level (
:infoor:warning) - Set up database backups
- Configure SSL/TLS certificates
- Set up monitoring and alerting
- Configure rate limiting appropriately
- Set up log aggregation
# Application
MIX_ENV=prod
PHX_SERVER=true
PHX_HOST=yourdomain.com
PORT=4000
SECRET_KEY_BASE=<generate-with-mix-phx-gen-secret>
# Database
DATABASE_URL=ecto://user:password@host:5432/database?ssl=true
POOL_SIZE=10
# JWT
JWT_SECRET=<at-least-64-character-secret-key>
# External APIs
MONZO_CLIENT_ID=your-production-client-id
MONZO_CLIENT_SECRET=your-production-client-secret
MONZO_API_URL=https://api.monzo.com
# Oban Queue Configuration (optional)
OBAN_QUEUES=banking:3,payments:2,notifications:3,default:1# Build production image
docker compose build --pull
# Or manually
docker build -t ledger-bank-api:latest .# Start all services
docker compose up -d
# View logs
docker compose logs -f app
# Check health
curl http://localhost:4000/api/health/ready# Inside the container
docker compose exec app bin/ledger_bank_api eval "LedgerBankApi.Release.migrate()"
# Or with docker-compose entrypoint (automatically runs migrations)Build an Elixir release for production:
# Install dependencies
mix deps.get --only prod
# Compile assets and code
MIX_ENV=prod mix compile
# Build release
MIX_ENV=prod mix release
# The release will be in _build/prod/rel/ledger_bank_api/Run the release:
# Start the server
_build/prod/rel/ledger_bank_api/bin/ledger_bank_api start
# Run migrations
_build/prod/rel/ledger_bank_api/bin/ledger_bank_api eval "LedgerBankApi.Release.migrate()"
# Stop the server
_build/prod/rel/ledger_bank_api/bin/ledger_bank_api stopExample Kubernetes manifests:
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: ledger-bank-api
spec:
replicas: 3
selector:
matchLabels:
app: ledger-bank-api
template:
metadata:
labels:
app: ledger-bank-api
spec:
containers:
- name: app
image: ledger-bank-api:latest
ports:
- containerPort: 4000
env:
- name: PHX_SERVER
value: "true"
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: db-credentials
key: url
- name: SECRET_KEY_BASE
valueFrom:
secretKeyRef:
name: app-secrets
key: secret_key_base
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: app-secrets
key: jwt_secret
livenessProbe:
httpGet:
path: /api/health/live
port: 4000
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /api/health/ready
port: 4000
initialDelaySeconds: 10
periodSeconds: 5
---
# service.yaml
apiVersion: v1
kind: Service
metadata:
name: ledger-bank-api
spec:
type: LoadBalancer
ports:
- port: 80
targetPort: 4000
selector:
app: ledger-bank-apiAlways run migrations before deploying new code:
# Using release task
bin/ledger_bank_api eval "LedgerBankApi.Release.migrate()"
# Or using mix (development/staging)
mix ecto.migrateSet up health check monitoring:
# Liveness check (container is alive)
curl http://your-domain/api/health/live
# Readiness check (can accept traffic)
curl http://your-domain/api/health/ready
# Detailed health (database, memory, etc.)
curl http://your-domain/api/health/detailedBuilt-in telemetry for:
- HTTP request duration
- Database query performance
- Oban job processing
- Custom business metrics
Structured JSON logging for production:
- Request/response logs with correlation IDs
- Error tracking with stack traces
- Security audit logs
- Business event logs
- APM: AppSignal, New Relic, or Datadog
- Logging: Papertrail, Loggly, or ELK Stack
- Monitoring: Prometheus + Grafana
- Error Tracking: Sentry or Rollbar
-
Install Elixir Version Manager (optional)
asdf install
-
Start Development Server
iex -S mix phx.server- Access Development Tools
- API: http://localhost:4000
- LiveDashboard: http://localhost:4000/dev/dashboard
- Sent Emails: http://localhost:4000/dev/mailbox
# Database
mix ecto.setup # Create, migrate, and seed
mix ecto.reset # Drop, recreate, migrate, and seed
mix ecto.migrate # Run pending migrations
mix ecto.rollback # Rollback last migration
mix ecto.gen.migration # Generate new migration
# Code Quality
mix format # Format code
mix credo # Static code analysis (if installed)
mix dialyzer # Type checking (if installed)
# Testing
mix test # Run all tests
mix test.watch # Watch mode (if installed)
mix test --cover # With coverage report
# Interactive Development
iex -S mix # Start IEx with project loaded
iex -S mix phx.server # Start IEx with Phoenix server
# Routes
mix phx.routes # Show all routes
# Dependencies
mix deps.get # Fetch dependencies
mix deps.update --all # Update all dependencies
mix deps.clean --all # Clean dependenciesThis project follows standard Elixir conventions:
# Format code automatically
mix format
# Check formatting
mix format --check-formatted- Contexts - Domain-driven organization (Accounts, Financial)
- Services - Business logic layer with standard behavior
- Policies - Pure permission functions
- Normalize - Data transformation layer
- Schemas - Database entities with Ecto
- Controllers - Thin HTTP layer, delegates to services
- Plugs - Reusable HTTP middleware
- Workers - Background job processing
-
Create Schema & Migration
mix ecto.gen.migration create_feature_name
-
Define Schema
# lib/ledger_bank_api/context/schemas/feature.ex defmodule LedgerBankApi.Context.Schemas.Feature do use Ecto.Schema # ... end
-
Create Service
# lib/ledger_bank_api/context/feature_service.ex defmodule LedgerBankApi.Context.FeatureService do @behaviour LedgerBankApi.Core.ServiceBehavior # ... end
-
Add Policy (if needed)
# In lib/ledger_bank_api/context/policy.ex def can_do_feature?(user, resource), do: ...
-
Create Controller
# lib/ledger_bank_api_web/controllers/feature_controller.ex defmodule LedgerBankApiWeb.FeatureController do use LedgerBankApiWeb.Controllers.BaseController # ... end
-
Add Routes
# lib/ledger_bank_api_web/router.ex scope "/api/features" do pipe_through [:api, :authenticated] # ... end
-
Write Tests
# test/ledger_bank_api/context/feature_service_test.exs # test/ledger_bank_api_web/controllers/feature_controller_test.exs
Having mastered these patterns, here's my learning roadmap:
-
GraphQL API โ Rebuild this with Absinthe
- Compare REST vs GraphQL for complex financial queries
- Learn N+1 query prevention with Dataloader
- Schema stitching for microservices
-
Real Banking Integration โ Connect to Plaid API
- OAuth2 flow for real bank connections
- Webhook handling for transaction updates
- Error handling for external API failures
-
Frontend Dashboard โ React + TypeScript
- Consume this API with proper JWT handling
- Real-time updates via Phoenix Channels/WebSockets
- Charts for financial data visualization
-
Event Sourcing โ Rebuild with Commanded/EventStore
- Learn CQRS pattern for financial audit trails
- Compare event-driven vs CRUD
- Time-travel debugging for payments
-
Distributed Systems โ Multi-node Elixir cluster
- Replace ETS cache with Redis (my CacheAdapter makes this easy)
- Learn clustering with
libcluster - Distributed Oban with Redis queues
-
Observability โ Add AppSignal or Datadog
- Custom telemetry events
- Distributed tracing with correlation IDs
- Alerting on business metrics (failed payments, auth failures)
-
Microservices โ Split into Auth, Payments, Accounts services
- Learn service boundaries and API gateways
- Distributed transactions / Saga pattern
- Service mesh with Istio
-
Machine Learning Integration โ Fraud detection
- Anomaly detection on payment patterns
- Real-time scoring with Nx (Elixir ML library)
- A/B testing for fraud rules
-
Mobile API โ Add GraphQL subscriptions
- Real-time balance updates
- Push notifications for transactions
- Offline-first mobile patterns
This is a learning/portfolio project, but contributions are welcome if you're learning too!
- Fork the repository
- Create a feature branch (
git checkout -b feature/amazing-feature) - Make your changes
- Run tests (
mix test) - Format code (
mix format) - Commit your changes (
git commit -m 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
- โ Include tests for new features
- โ Update documentation as needed
- โ Follow existing code style
- โ Ensure all tests pass
- โ Keep commits focused and atomic
- โ Write clear commit messages
- โ Update CHANGELOG.md (if applicable)
- All PRs require approval from maintainers
- CI must pass (tests, formatting, etc.)
- Address review feedback promptly
- Keep PR scope focused and reasonable
This project is licensed under the MIT License - see the LICENSE file for details.
Built with:
- Elixir - Functional, concurrent programming language
- Phoenix Framework - Productive web framework
- Ecto - Database wrapper and query generator
- Oban - Robust background job processing
- Joken - JWT implementation
Hi! I'm Rafael, and I built this project to learn production Elixir patterns. If you're:
- ๐ Hiring for Elixir/backend roles โ Let's talk about what I learned building this
- ๐ Learning Elixir too โ Feel free to ask questions or open issues
- ๐ง Want to contribute โ PRs welcome! See patterns you'd do differently? Let's discuss!
Connect with me:
- ๐ง Email: [email protected]
- ๐ผ LinkedIn: Rafael Rojas (if applicable)
- ๐ GitHub: @rafaelRojasVi
- ๐ Portfolio: rafaelrojas.dev (if you have one)
Check out my other learning projects:
- ๐ง [Your other Elixir project]
- ๐ง [Your frontend project]
- ๐ง [Your infrastructure project]
This project was influenced by:
Books:
- Designing Elixir Systems with OTP by James Edward Gray II & Bruce A. Tate
- Programming Phoenix 1.4 by Chris McCord, Bruce Tate, & Josรฉ Valim
Blog Posts & Talks:
Open Source Projects:
- Phoenix Framework - Web framework patterns
- Oban - Background job inspiration
- Plaid Elixir - Banking integration patterns
Q: Why not use Phoenix.Token instead of Joken?
A: I wanted to learn JWT internals and implement token rotation myself. Phoenix.Token is great, but building with Joken taught me about claims validation, signers, and security considerations.
Q: Why ETS instead of Redis for cache?
A: I implemented CacheAdapter behavior so switching to Redis is one config change. ETS keeps the project simple to run locally, but the architecture is Redis-ready.
Q: Why so many test files?
A: I wanted to learn different testing strategies:
- Integration tests (full user flows)
- Security tests (timing attacks, injection)
- Performance tests (N+1 queries, concurrent updates)
- Edge case tests (null bytes, boundary conditions)
Q: Is this production-ready?
A: The patterns are production-grade, but you'd need:
- Real banking integration (Plaid, Stripe)
- Distributed cache (Redis)
- Proper secrets management (Vault)
- APM/monitoring (AppSignal, New Relic)
- Rate limiting per user (currently per IP)
Made with โค๏ธ using Elixir and Phoenix
Built as a learning project โข Not for production financial transactions