Знову підсумовую свій досвід у Golang і показую створений проєкт
Усім привіт!Знов вирішив свій досвід що понад 10 років у go викласти до купи та на россуд та ще й може потрібно кому буде.
Вирішив створити звичайний та не зовсім RESTful API на golang згідно із накопиченими практиками
та з імплементацією важких та важливих речей аля правильний auth та структура уцілому...
Вже бачив дуже багато проектів (та не тільки на go)котрі начебто і функціонал відтворюють
але ні уякому разі не розповідають про то як цим користуватися або як це може бути корисним.
Як взагалі ефективно має бути та під k8s ляже просто та зручно...
Для Вашого власного біснесу або для пошуку новоі позиціі у праці з грошима)
Головне посилання тут github.com/...asilex/promenade/tree/dev
Тобто. На github.com/basilex/promenade усе уцілому
До речі, викладу відправне прямо тут шоб цікавіше було до зацікавлених)
Promenade
Production-ready REST API built with Clean Architecture, featuring PostgreSQL with UUID v7, comprehensive testing infrastructure, JWT authentication, and API versioning.
Key Features
- Clean Architecture — Clear separation of concerns (Domain, Use Case, Adapter, Infrastructure)
- UUID v7 Primary Keys — Time-ordered UUIDs for optimal performance (2x faster than v4)
- RBAC System — Role-Based Access Control with wildcard permissions and 5 system roles
- Structured Logging — slog with JSON/text format, context fields (request_id, user_id)
- Comprehensive Testing — 388 tests total across all layers — 100% passing
- Unit: 183 tests (entity validation + domain logic)
- Integration: 91 tests (repository operations with real PostgreSQL)
- Smoke: 114 tests (end-to-end critical flows with real database)
- JWT Authentication — Secure token-based auth with refresh tokens
- API Versioning — v1 and v2 with backward compatibility
- PostgreSQL + sqlx — No ORM, pure SQL with transaction support
- Swagger Documentation — Auto-generated API docs for both versions
- Docker Ready — Full Docker Compose setup for development and testing
- Database Migrations — golang-migrate for version control
- High Performance — Gin framework with graceful shutdown
Quick Start
Prerequisites
- Go 1.21+
- Docker & Docker Compose
- Make
- golang-migrate (optional, installed via make install)
Installation
# Clone the repository
git clone github.com/basilex/promenade.git
cd promenade
# Install dependencies and tools
make install
# Start PostgreSQL via Docker
make docker-up
# Run database migrations
make migrate-up
# Start development server
make dev
Server will start on http://localhost:8081
Default Login Credentials
The system includes pre-configured users with different RBAC roles for development and testing:
# System Administrator (superadmin role — bootstrap user)
Email: [email protected]
Password: passw0rd
Role: Full system access (*)
# Super Administrator (superadmin role — testing)
Email: [email protected]
Password: passw0rd
Role: Full system access (*)
# Administrator (admin role)
Email: [email protected]
Password: passw0rd
Role: User/content management
# Moderator (moderator role)
Email: [email protected]
Password: passw0rd
Role: Content moderation
# Regular User (user role)
Email: [email protected]
Password: 03041965
Role: Basic user operations
[!] Important: Change these passwords before deploying to production!
-> Full credentials reference: See docs/CREDENTIALS.md for complete list with API examples.
Docker Setup (Alternative)
Run everything in Docker containers:
# Build and run all services (PostgreSQL, Redis, Migrations, API)
make docker-run
# Or step by step:
make docker-build VERSION=0.1.0 ENV=dev
make docker-up
# Check health
curl http://localhost:8080/api/v1/health
Database Auto-Creation: PostgreSQL container automatically creates three databases on first startup:
- promenade_prod — Production database (with migrations applied via migrate service)
- promenade_dev — Development database (requires manual make migrate-up for local dev)
- promenade_test — Test database (used by integration tests)
Clean Slate: Use make docker-clean to remove all containers and volumes, then make docker-up to recreate with fresh databases.
-> Docker details: See docker/README.md for comprehensive Docker documentation.
Quick Test
# Run all tests (unit + integration)
make test
# Or run integration tests only
make test-integration
# Test authentication with default user (local dev)
curl -X POST http://localhost:8081/api/v1/auth/login \
-H “Content-Type: application/json” \
-d ’{"email":"[email protected]","password":"passw0rd“}’
# Test authentication (Docker)
curl -X POST http://localhost:8080/api/v1/auth/login \
-H “Content-Type: application/json” \
-d ’{"email":"[email protected]","password":"passw0rd“}’
-> API Access
API Endpoints
- GET /api — API information with all available versionscurl http://localhost:8081/api
# Returns: service info, available versions (v1, v2), links to documentation - GET /api/v1 — API v1 informationcurl http://localhost:8081/api/v1
# Returns: version info, base path, documentation, health check links - GET /api/v2 — API v2 informationcurl http://localhost:8081/api/v2
# Returns: version info, base path, documentation, health check links
Quick Links
Local Development (port 8081):
- API Root: localhost:8081/api
- API v1 Base: localhost:8081/api/v1
- API v2 Base: localhost:8081/api/v2
- Swagger v1: localhost:8081/...1/docs/swagger/index.html
- Swagger v2: localhost:8081/...2/docs/swagger/index.html
- Health Check: localhost:8081/api/v1/health
Docker (port 8080):
- API Root: localhost:8080/api
- Swagger v1: localhost:8080/...1/docs/swagger/index.html
- Health Check: localhost:8080/api/v1/health
Swagger UI Authentication
How to authenticate in Swagger UI (browser):
- Login via /api/v1/auth/login endpoint:{
“email”: “[email protected]”,
“password”: “passw0rd”
} - Copy the access_token from response (e.g., eyJhbGciOiJIUzI1NiIs...)
- Click the “Authorize” button 🔓 (green lock icon at the top right)
- In the “Value” field, enter:Bearer eyJhbGciOiJIUzI1NiIs...
⚠️ IMPORTANT: You must type the word Bearer, then a space, then your token
- Click “Authorize” and close the dialog
- All protected endpoints (with 🔒 icon) will now work automatically
Common mistake: Entering just the token without Bearer prefix results in 401 Unauthorized.
cURL equivalent (for comparison):
# Get token
TOKEN=$(curl -s -X POST http://localhost:8081/api/v1/auth/login \
-H “Content-Type: application/json” \
-d ’{"email":"[email protected]","password":"passw0rd"}’ | jq -r ’.data.access_token’)
# Use token (notice “Bearer ” prefix)
curl http://localhost:8081/api/v1/roles \
-H “Authorization: Bearer $TOKEN”
Error Handling
All errors return structured JSON responses via ErrorHandler in shared layer:
- 404 Not Found — Route doesn’t exist:{
“success”: false,
“message”: “route not found”,
“error”: “The requested endpoint does not exist”,
“timestamp”: 1766125580
} - 405 Method Not Allowed — HTTP method not supported:{
“success”: false,
“message”: “method not allowed”,
“error”: “The HTTP method is not supported for this endpoint”,
“timestamp”: 1766125580
}
Server Timestamp: All responses include timestamp field (Unix seconds) for:
- Client-server time synchronization
- Latency measurement (client_time — server_timestamp)
- Debugging and log correlation across timezones
- Cache freshness detection
Implementation: See internal/adapter/http/shared/handler/error_handler.go — centralized HTTP error handling with unit test coverage.
-> Documentation
Technical Documentation
- Authorization Guide — RBAC middleware with permissions, roles, and usage examples
- Logging Guide — Structured logging with slog (JSON/text format, context fields)
- Testing Guide — Comprehensive testing setup and best practices
- Testing Infrastructure — Test infrastructure overview
- Validation — Multi-layer validation strategy and best practices
- UUID v7 Migration — Migrating from UUID v4 to v7
- ID Strategies — Primary key strategy recommendations
- Auth Schema — Database schema for authentication system
- Test Results — Current test coverage and results
- User Profiles Test Results — User profiles module test coverage (72 tests)
Language Policy: All documentation and code comments are in English. Russian versions (.ru.md) are kept for reference.
Database Schema
Complete database schema with relationships and key constraints:
Key Features:
- UUID v7 for all primary keys (time-ordered, better performance than UUID v4)
- RBAC (Role-Based Access Control) — Flexible permission system with wildcard support (*:*, posts:*)
- 5 system roles: superadmin, admin, moderator, user, guest
- Granular permissions: 33 predefined permissions (users:create, posts:delete, etc.)
- Optional role expiration for temporary access grants
- Soft deletes on user posts and comments (deleted_at)
- Nested comments via self-referencing parent_id in post_comments
- JSONB for flexible data (social links, preferences, tags, featured images)
- Enums for type safety (user_status, post_status, country_region)
- Composite primary keys for junction tables (comment_likes, country_currencies, role_permissions, user_roles)
- Cascading deletes to maintain referential integrity
- Unique constraints to prevent duplicates (email, nickname, slug per user, permission resource:action)
Development Commands
Modular Makefile System — Commands organized by context (see MAKEFILE_ARCHITECTURE.md)
Quick Reference
make help # Show all available commands (grouped by module)
make dev # Start development server (most common workflow)
make test # Run all tests (unit + integration + smoke)
make docker-run # Build and run in Docker
Development Workflow (Makefile.dev.mk)
make install # Install tools (swag, migrate, golangci-lint)
make dev # Start server (postgres + migrations + app)
make build # Build binary to bin/promenade
make run # Run compiled binary
make lint # Run golangci-lint
make fmt # Format code (go fmt + gofmt -s)
make generate # Generate entity boilerplate
make config-show # Show current configuration
Testing (Makefile.test.mk)
make test # Run all tests (unit + integration)
make test-unit # Unit tests only (~5s)
make test-integration # Integration tests (~35s, auto-starts DB)
make test-smoke # Smoke tests (~4.5s, critical flows)
make test-coverage # Generate HTML coverage report
make test-db-start # Start test DB (port 5433)
make test-db-stop # Stop test DB
Production/DevOps (Makefile.prod.mk)
# Docker
make docker-build # Build image (VERSION=0.1.0 ENV=dev)
make docker-run # Build + start containers
make docker-up # Start services
make docker-down # Stop services
make docker-logs # View logs
make docker-clean # Remove containers + volumes
# Migrations
make migrate-up # Apply migrations
make migrate-down # Rollback last migration
make migrate-create # Create migration (NAME=xxx)
# Documentation
make swagger-all # Generate v1 + v2 Swagger docs
# Cleanup
make clean # Remove artifacts
Architecture
This project strictly follows Clean Architecture principles with four distinct layers:
┌─────────────────────────────────────────────────────────────┐
│ HTTP Handlers (Gin) │ ← Adapter Layer
│ DTOs, Mappers, Routes │
├─────────────────────────────────────────────────────────────┤
│ Use Cases │ ← Use Case Layer
│ Business Logic Orchestration │
├─────────────────────────────────────────────────────────────┤
│ Domain Entities │ ← Domain Layer
│ Repository Interfaces (Ports) │
├─────────────────────────────────────────────────────────────┤
│ Repository Implementations │ ← Infrastructure
│ PostgreSQL, Config, JWT │
└─────────────────────────────────────────────────────────────┘
Layers Explained
LayerResponsibilityDependencies
| Domain | Business entities & repository interfaces | None (independent) |
| Use Case | Application business rules | Domain only |
| Adapter | HTTP handlers, DTOs, mappers | Use Case, Domain |
| Infrastructure | Database, config, external services | Domain interfaces |
Dependency Rule: Inner layers never depend on outer layers. Dependencies point inward.
Project Structure
promenade/
├── cmd/api/ # Application entry point
│ └── main.go # Bootstrap, DI, server setup
├── internal/
│ ├── domain/
│ │ ├── entity/ # Business entities (User, Permission, Role, UserProfile, UserContact, UserPost, Country, Currency, Session)
│ │ └── repository/ # Repository interfaces (ports)
│ ├── usecase/ # Business logic orchestration
│ │ ├── auth_usecase.go # Login, register, refresh, logout
│ │ ├── permission_usecase.go # RBAC permissions management
│ │ ├── role_usecase.go # RBAC roles management
│ │ ├── user_contact_usecase.go # User contacts management
│ │ ├── user_profile_usecase.go # User profiles, privacy, moderation
│ │ ├── user_post_usecase.go # Blog posts, publishing, engagement
│ │ ├── country_usecase.go # Countries CRUD
│ │ └── currency_usecase.go # Currencies CRUD
│ ├── adapter/
│ │ ├── http/
│ │ │ ├── shared/middleware/ # Auth, RBAC authorization, CORS, logging, recovery
│ │ │ ├── v1/ # API v1 (handlers, DTOs, routes)
│ │ │ └── v2/ # API v2 (handlers, DTOs, routes)
│ │ └── repository/postgres/ # Repository implementations (sqlx)
│ └── infrastructure/
│ ├── config/ # Configuration loader
│ ├── database/ # PostgreSQL connection & transactions
│ └── logger/ # Structured logging
├── pkg/
│ ├── jwt/ # JWT token manager
│ ├── ptr/ # Reference helpers for nullable fields
│ ├── uuidv7/ # UUID v7 generator
│ ├── pagination/ # Pagination helpers
│ └── validator/ # Request validation
├── test/
│ ├── helpers/ # Test database setup & fixtures
│ │ ├── database.go # TestDB with cleanup
│ │ └── fixtures.go # User & session fixtures
│ ├── integration/ # Integration tests (planned)
│ ├── e2e/ # End-to-end tests (planned)
│ └── mocks/ # Mock repositories (UserProfile, etc.)
├── migrations/ # Database migrations (golang-migrate)
├── docker/
│ ├── docker-compose.yml # Dev database (port 5432)
│ ├── docker-compose.test.yml # Test database (port 5433)
│ └── Dockerfile # Production image
├── docs/ # Technical documentation
├── scripts/ # Helper scripts & generators
└── Makefile # Development commands
* Working with Nullable Fields (pkg/ref)
When working with database entities that have nullable fields (mapped to SQL NULL), Go requires using pointer types (*string, *time.Time, etc.). The pkg/ref package provides convenient helpers to avoid verbose manual pointer creation and prevent common mistakes.
Why Reference Helpers?
// [X] Manual approach — verbose and error-prone
reason := “Violation of terms”
user.SuspendedReason = &reason // Easy to forget “*” after 8 hours at computer
until := time.Now().Add(7 * 24 * time.Hour)
user.SuspendedUntil = &until
// [+] Using ref package — clean and safe
user.SuspendedReason = ref.String("Violation of terms“)
user.SuspendedUntil = ref.Time(time.Now().Add(7 * 24 * time.Hour))
Available Helpers
Constructor functions (value → pointer):
ref.String(s string) *string // “hello” → *“hello”
ref.Time(t time.Time) *time.Time // time.Now() → *time.Now()
ref.UUID(u uuidv7.UUID) *uuidv7.UUID // uuid → *uuid
ref.Int(i int) *int // 42 → *42
ref.Bool(b bool) *bool // true → *true
Safe getters (pointer → value with defaults):
ref.StringValue(s *string) string // nil → “", *“hello” → “hello”
ref.TimeValue(t *time.Time) time.Time // nil → time.Time{}, *now → now
ref.UUIDValue(u *uuidv7.UUID) uuidv7.UUID // nil → uuid.UUID{}, *id → id
ref.IntValue(i *int) int // nil → 0, *42 → 42
ref.BoolValue(b *bool) bool // nil → false, *true → true
Getters with custom defaults:
ref.StringOr(s *string, default string) string
ref.TimeOr(t *time.Time, default time.Time) time.Time
ref.IntOr(i *int, default int) int
ref.BoolOr(b *bool, default bool) bool
Utility functions:
ref.IsNil[T any](p *T) bool // Check if pointer is nil
ref.IsSet(s *string) bool // Check if string pointer is not nil AND not empty
Real-World Examples
Creating entities with nullable fields:
// User suspension
user.Suspend(reason, ref.Time(time.Now().Add(7*24*time.Hour)))
// User profile with optional fields
profile := &entity.UserProfile{
UserID: userID,
Nickname: “john_doe”,
Bio: ref.String("Software engineer“),
DateOfBirth: ref.Time(time.Date(1990, 1, 1, 0, 0, 0, 0, time.UTC)),
CountryID: ref.UUID(countryUUID),
Timezone: “America/New_York”,
}
Test assertions:
// [X] Old way — manual dereferencing
assert.Equal(t, “Violation of terms”, *user.SuspendedReason)
assert.Equal(t, expectedTime, *user.SuspendedUntil)
// [+] New way — safe getters
assert.Equal(t, “Violation of terms”, ref.StringValue(user.SuspendedReason))
assert.Equal(t, expectedTime, ref.TimeValue(user.SuspendedUntil))
Conditional logic:
// Check if bio is set and not empty
if ref.IsSet(profile.Bio) {
// Display bio
fmt.Println("Bio:", ref.StringValue(profile.Bio))
} else {
// Show default message
fmt.Println("Bio: Not provided")
}
// Get value with fallback
displayName := ref.StringOr(profile.DisplayName, profile.Nickname)
Benefits
- Type-safe — Compiler catches mismatches
- -> Less verbose — No temporary variables needed
-
- Intention-clear — ref.String("value“) explicitly shows nullable intent
- [!] Fewer bugs — Eliminates “forgot to add *” mistakes after long coding sessions
-
- Test-friendly — Safe dereferencing in assertions without panic risk
Nullable Fields Philosophy
// Use regular types for required fields (NOT NULL in database)
Email string // Always has value, minimum ""
Name string // Required
CreatedAt time.Time // NOT NULL DEFAULT NOW()
// Use pointers for optional fields (NULL in database)
MiddleName *string // nil = not set, &"" = empty, &"John" = value
LastLoginAt *time.Time // nil = never logged in, &time = last login time
SuspendedUntil *time.Time // nil = not suspended, &time = suspended until
This approach allows distinguishing between three states:
- Not set (nil) — field was never provided
- Empty (&"") — field was explicitly cleared
- Value (&"text") — field has actual data
📨 Event Bus System
The project implements a transport-agnostic event bus for asynchronous communication between components. This enables scalable, loosely-coupled architecture within the monolith, with a clear path to microservices when needed.
Architecture Overview
┌─────────────────────────────────────────────────────────────────┐
│ Application Layer │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ AuthUseCase │────────>│ Event Bus │<────────┐ │
│ └──────────────┘ Publish └──────────────┘ Subscribe │
│ User │ │ │
│ Registered │ ┌────▼────────┐ │
│ │ │ Email │ │
│ │ │ Service │ │
│ │ └─────────────┘ │
│ ┌────▼────────┐ │
│ │ Analytics │ │
│ │ Service │ │
│ └─────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Flow:
1. User registers → AuthUseCase publishes UserRegisteredEvent
2. Event Bus dispatches to all subscribers (non-blocking)
3. Email Service sends welcome email in background
4. Analytics Service tracks registration metrics
5. Main request returns immediately (user doesn’t wait)
Key Benefits
BenefitDescription
| Non-blocking | Publish() returns immediately — user doesn’t wait for side effects |
| Decoupling | Use cases don’t know about email/analytics — just publish events |
| Scalability | Easy to add new subscribers without changing existing code |
| Evolution Path | Start with in-memory bus → Redis Pub/Sub → NATS → Kafka |
| Testing | Mock bus for tests, no actual email/analytics calls |
| Async Processing | Worker pool handles events concurrently in goroutines |
Available Implementations
1. In-Memory Bus (Current)
Use case: Development, testing, and simple monolith deployments
// Production code (cmd/api/main.go)
busConfig := bus.NewBusConfig(
cfg.Bus.WorkerPoolSize, // From .env: BUS_WORKER_POOL_SIZE
cfg.Bus.BufferSize, // From .env: BUS_BUFFER_SIZE
cfg.Bus.RetryAttempts, // From .env: BUS_RETRY_ATTEMPTS
cfg.Bus.RetryDelay, // From .env: BUS_RETRY_DELAY
)
eventBus := memory.NewMemoryBus(busConfig)
defer eventBus.Close(ctx)
Features:
- [+] Zero external dependencies
- [+] Configurable worker pool (10 goroutines default)
- [+] Buffered message queue (1000 messages default)
- [+] Graceful shutdown with proper cleanup
- [+] Built-in health checks and statistics
- [!] No persistence — events lost on restart
- [!] Single-process only — not suitable for horizontal scaling
2. Redis Pub/Sub (Planned)
Use case: Multi-instance deployments with shared state
// Future implementation
busConfig := bus.NewBusConfig(...)
eventBus := redis.NewRedisBus(busConfig, redisClient)
Features:
- [+] Multi-process support (horizontal scaling)
- [+] Pub/Sub pattern for real-time delivery
- [!] No guaranteed delivery — subscribers must be online
- [!] No message persistence after delivery
3. NATS/Kafka (Future)
Use case: Microservices with guaranteed delivery
// Future implementation
eventBus := nats.NewNATSBus(busConfig, natsConn)
// or
eventBus := kafka.NewKafkaBus(busConfig, kafkaProducer)
Features:
- [+] Persistent message storage
- [+] At-least-once delivery guarantees
- [+] Message replay capability
- [+] Dead letter queues for failures
- [!] More complex infrastructure
Configuration
Event bus is configured via environment variables in .env files:
# .env.development / .env.production
BUS_WORKER_POOL_SIZE=10 # Concurrent workers processing events
BUS_BUFFER_SIZE=1000 # Internal message queue size
BUS_RETRY_ATTEMPTS=3 # Retry failed handlers
BUS_RETRY_DELAY=1s # Delay between retries
Config struct (internal/infrastructure/config/config.go):
type BusConfig struct {
WorkerPoolSize int // Number of concurrent workers
BufferSize int // Message buffer capacity
RetryAttempts int // Max retry attempts on failure
RetryDelay time.Duration // Delay between retries
}
Handler Retry Logic & Backoff
The event bus includes built-in retry logic for all event handlers. If a handler returns an error, the bus will automatically retry processing the event up to the configured number of attempts (BUS_RETRY_ATTEMPTS).
- Exponential Backoff: Each retry is delayed using exponential backoff, starting from BUS_RETRY_DELAY and increasing by a multiplier (default 2x) for each subsequent attempt, up to a maximum delay.
- Configurable: All retry parameters are set via environment variables and config struct.
- Structured Logging: All handler errors, retry attempts, and final failures are logged with structured context (topic, message ID, attempt, error).
- Non-blocking: Event publishing is always non-blocking; main business logic is never interrupted by handler failures.
Example retry sequence (with BUS_RETRY_ATTEMPTS=3, BUS_RETRY_DELAY=1s):
- 1st attempt: immediate
- 2nd attempt: after 1s
- 3rd attempt: after 2s
If all attempts fail, the error is logged and the event is dropped (in-memory bus).
Publishing Events
Step 1: Define domain event (internal/domain/event/user_events.go):
type UserRegisteredEvent struct {
bus.BaseEvent
UserID uuid.UUID `json:"user_id"`
Email string `json:"email"`
Name string `json:"name"`
}
func NewUserRegisteredEvent(userID uuid.UUID, email, name string) *UserRegisteredEvent {
return &UserRegisteredEvent{
BaseEvent: bus.NewBaseEvent(bus.TopicUserRegistered, userID),
UserID: userID,
Email: email,
Name: name,
}
}
Step 2: Publish from use case (internal/usecase/auth_usecase.go):
func (uc *authUseCase) Register(ctx context.Context, email, name, password string) (*entity.User, error) {
// 1. Create user in database
user, err := uc.userRepo.Create(ctx, newUser)
if err != nil {
return nil, err
}
// 2. Publish event (non-blocking)
userEvent := event.NewUserRegisteredEvent(user.ID, user.Email, user.Name)
if err := uc.eventBus.Publish(ctx, userEvent.Type(), userEvent); err != nil {
logger.Error("Failed to publish user registered event",
slog.Any("error", err),
slog.String("user_id", user.ID.String()))
// Don’t fail registration if event publishing fails
}
// 3. Return immediately — email is sent in background
return user, nil
}
Key principle: Event publishing errors are logged but not propagated. The main operation (user registration) should succeed even if events fail.
Subscribing to Events
Email notification service (internal/infrastructure/notification/email_service.go):
type EmailService struct {
bus bus.Bus
sender EmailSender
templates *template.Template
}
// Start subscribes to events
func (s *EmailService) Start(ctx context.Context) error {
// Subscribe to multiple topics
s.bus.Subscribe(bus.TopicUserRegistered, s.handleUserRegistered)
s.bus.Subscribe(bus.TopicUserEmailVerified, s.handleUserEmailVerified)
s.bus.Subscribe(bus.TopicUserPasswordChanged, s.handleUserPasswordChanged)
s.bus.Subscribe(bus.TopicUserSuspended, s.handleUserSuspended)
s.bus.Subscribe(bus.TopicUserBanned, s.handleUserBanned)
return nil
}
// Handle user registration event
func (s *EmailService) handleUserRegistered(ctx context.Context, e bus.Event) error {
evt, ok := e.(*event.UserRegisteredEvent)
if !ok {
return fmt.Errorf("unexpected event type: %T“, e)
}
// Render HTML template
data := map[string]interface{}{
“Name”: evt.Name,
“Email”: evt.Email,
“UserID”: evt.UserID.String(),
“LoginURL”: s.appURL + “/api/v1/auth/login”, // From config (APP_URL)
“AppName”: s.appName, // From config (APP_NAME)
“Year”: time.Now().Year(),
}
html, err := s.renderTemplate("welcome.html", data)
if err != nil {
return fmt.Errorf("failed to render template: %w“, err)
}
// Send email (happens in background goroutine)
email := Email{
To: evt.Email,
Subject: “Welcome to Promenade!”,
HTML: html,
}
return s.sender.Send(ctx, email)
}
Production initialization (cmd/api/main.go):
// Initialize Event Bus
busConfig := bus.NewBusConfig(
cfg.Bus.WorkerPoolSize, // From .env: BUS_WORKER_POOL_SIZE=10
cfg.Bus.BufferSize, // From .env: BUS_BUFFER_SIZE=1000
cfg.Bus.RetryAttempts, // From .env: BUS_RETRY_ATTEMPTS=3
cfg.Bus.RetryDelay, // From .env: BUS_RETRY_DELAY=1s
)
eventBus := memory.NewMemoryBus(busConfig)
defer eventBus.Close(context.Background())
// Initialize Email Service with config
emailSender := notification.NewMockEmailSender() // TODO: replace with real SMTP in production
emailService, err := notification.NewEmailService(
eventBus,
emailSender,
“templates/email”, // Template directory
cfg.Email.FromAddress, // From .env: EMAIL_FROM_ADDRESS
cfg.Email.FromName, // From .env: EMAIL_FROM_NAME
cfg.Email.AppURL, // From .env: APP_URL
cfg.Email.AppName, // From .env: APP_NAME
)
if err != nil {
logger.Fatal("Failed to create email service", slog.Any("error", err))
}
// Start service (subscribes to events)
if err := emailService.Start(context.Background()); err != nil {
logger.Fatal("Failed to start email service", slog.Any("error", err))
}
logger.Info("Email notification service started (async via event bus)",
slog.String("templates_path“, “templates/email”),
slog.String("from_address", cfg.Email.FromAddress),
slog.String("app_url“, cfg.Email.AppURL))
Environment configuration (.env.development):
# Email Configuration
[email protected]
EMAIL_FROM_NAME=Promenade Team
# Application
APP_NAME=Promenade
APP_URL=http://localhost:8081
Email Templates
Templates are externalized in templates/email/ directory:
templates/email/
├── welcome.html # New user registration
├── email_verified.html # Email verification success
├── password_changed.html # Security alert
├── account_suspended.html # Temporary suspension
├── account_banned.html # Permanent ban
└── README.md # Template documentation
Benefits of external templates:
- [+] Change email design without redeploying service
- [+] Professional HTML emails with CSS styling
- [+] A/B testing different email variants
- [+] Designer-friendly (no Go code required)
- [+] Version control for email content
Example template (templates/email/welcome.html):
🎉 Welcome to Promenade!
Standard Topics
Predefined topic constants in pkg/bus/topics.go:
const (
// User Management
TopicUserRegistered = “user.registered”
TopicUserActivated = “user.activated”
TopicUserSuspended = “user.suspended”
TopicUserBanned = “user.banned”
TopicUserEmailVerified = “user.email.verified”
TopicUserPasswordChanged = “user.password.changed”
// Content Management
TopicPostPublished = “post.published”
TopicPostUnpublished = “post.unpublished”
TopicCommentAdded = “comment.added”
TopicCommentRemoved = “comment.removed”
)
Naming convention: {domain}.{entity}.{action} for clarity and consistency.
Testing
Unit Tests with Mock Bus
func TestAuthUseCase_Register(t *testing.T) {
mockBus := new(MockBus)
authUC := NewAuthUseCase(userRepo, sessionRepo, jwtManager, mockBus)
// Test registration logic
user, err := authUC.Register(ctx, “[email protected]”, “John”, “password”)
require.NoError(t, err)
// Verify event was published (but don’t actually send email)
mockBus.AssertCalled(t, “Publish”, mock.Anything, “user.registered”, mock.Anything)
}
Integration Tests with Real Bus
func TestEventBusIntegration(t *testing.T) {
// Use in-memory bus with empty template path (fallback templates)
eventBus := memory.NewDefaultMemoryBus()
defer eventBus.Close(context.Background())
emailSender := notification.NewMockEmailSender()
// Parameters: eventBus, sender, templatesPath, fromAddress, fromName, appURL, appName
emailService, err := notification.NewEmailService(
eventBus,
emailSender,
“", // empty = use fallback templates
“[email protected]”, // from config: EMAIL_FROM_ADDRESS
“Promenade Team”, // from config: EMAIL_FROM_NAME
“http://localhost:8081”, // from config: APP_URL
“Promenade”, // from config: APP_NAME
)
require.NoError(t, err)
require.NoError(t, emailService.Start(ctx))
// Publish event
userEvent := event.NewUserRegisteredEvent(uuid.New(), “[email protected]”, “John”)
err = eventBus.Publish(ctx, userEvent.Type(), userEvent)
require.NoError(t, err)
// Wait for async processing
time.Sleep(100 * time.Millisecond)
// Verify email was “sent” to mock
require.Equal(t, 1, len(emailSender.GetSentEmails()))
assert.Equal(t, “[email protected]”, emailSender.GetSentEmails()[0].To)
}
Performance & Monitoring
Bus Statistics
stats := eventBus.Stats()
fmt.Printf("Topics: %d\n", stats["total_topics"])
fmt.Printf("Subscribers: %d\n", stats["total_subscribers"])
fmt.Printf("Messages Published: %d\n", stats["messages_published"])
fmt.Printf("Messages Processed: %d\n", stats["messages_processed"])
Health Check
if err := eventBus.Health(ctx); err != nil {
logger.Error("Event bus is unhealthy", slog.Any("error", err))
}
Performance Characteristics
In-Memory Bus:
- Latency: < 1ms to publish (returns immediately)
- Throughput: 10,000+ events/sec with default config
- Worker Pool: Limits concurrent processing (prevents resource exhaustion)
- Graceful Shutdown: Waits for in-flight events to complete
Tuning for production:
# High-volume deployment
BUS_WORKER_POOL_SIZE=50 # More concurrent handlers
BUS_BUFFER_SIZE=10000 # Larger queue for spikes
BUS_RETRY_ATTEMPTS=5 # More aggressive retries
BUS_RETRY_DELAY=2s # Longer backoff
Migration Path: Monolith → Microservices
Phase 1: Monolith with Event Bus (Current)
┌─────────────────────────────────────────────────────────────────┐
│ Application Layer │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ AuthUseCase │────────>│ Event Bus │<────────┐ │
│ └──────────────┘ Publish └──────────────┘ Subscribe │
│ User │ │ │
│ Registered │ ┌────▼────────┐ │
│ │ │ Email │ │
│ │ │ Service │ │
│ │ └─────────────┘ │
│ ┌────▼────────┐ │
│ │ Analytics │ │
│ │ Service │ │
│ └─────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Phase 2: Multi-Instance with Redis
┌──────────────┐ ┌──────────────┐
│ Instance 1 │ │ Instance 2 │
│ ┌──────────┐ │ │ ┌──────────┐ │
│ │ Use Case │─┼───┐ ┐───┼─│ Email │ │
│ └──────────┘ │ │ │ │ │ Service │ │
└──────────────┘ │ │ │ └──────────┘ │
▼ ▼ └──────────────┘
┌──────────┐
│ Redis │
│ Pub/Sub │
└──────────┘
Phase 3: Microservices with NATS/Kafka
┌────────────┐ ┌─────────┐ ┌─────────────┐
│ API │───>│ NATS/ │───>│ Email │
│ Service │ │ Kafka │ │ Microservice│
└────────────┘ └─────────┘ └─────────────┘
│
└─────────>┌─────────────┐
│ Analytics │
│ Microservice│
└─────────────┘
Key insight: Same event publishing code works across all phases. Only the bus implementation changes:
// Phase 1: In-memory
eventBus := memory.NewMemoryBus(config)
// Phase 2: Redis
eventBus := redis.NewRedisBus(config, redisClient)
// Phase 3: NATS
eventBus := nats.NewNATSBus(config, natsConn)
Best Practices
- Events are immutable — Never modify event after publishing
- Events are facts — Past tense naming (UserRegistered, not RegisterUser)
- Idempotent handlers — Handle duplicate events gracefully
- Don’t fail operations on event errors — Log and continue
- Keep events small — Only essential data (use IDs, not full objects)
- Version events — Add EventVersion field for schema evolution
- Monitor dead letters — Track and retry failed events
- Use correlation IDs — Trace events across services
Demo
Run the event bus demo to see async email notifications in action:
cd /Users/basilex/Workspace/src/promenade
go run examples/event_bus_demo/main.go
Output:
* Event Bus Demo — Async Email Notifications
================================================
[+] Email service started and listening for events...
-> Simulating user registration: Demo User ([email protected])
[+] Event published to bus (returns immediately)
⏳ Email being sent in background goroutine...
-> Emails sent: 1
1. To: [email protected]
Subject: Welcome to Promenade!
-> Event Bus Stats:
Topics: 5
Subscribers: 5
Messages Published: 1
Messages Processed: 1
* Key Takeaways:
• Publish() returns immediately — non-blocking
• Email sent asynchronously in worker pool
• User doesn’t wait for email delivery
Further Reading
- Event Bus README — Detailed technical documentation
- Configuration Guide — Template and config externalization
- Integration Tests — Full test suite examples
Configuration
Environment Files
The project uses a hierarchical environment configuration:
FilePurposeCommitted?Priority
| .env | Base defaults | [+] Yes | Lowest |
| .env.development | Development settings | [+] Yes | Medium |
| .env.local | Personal overrides | [X] No | Highest |
| .env.production | Production secrets | [X] No | Production |
Key Configuration Variables
# Application
APP_NAME=Promenade
APP_URL=http://localhost:8081
# Server Configuration
SERVER_PORT=8081
ENVIRONMENT=development # development, production
# Database Configuration
DB_HOST=localhost
DB_PORT=5432
DB_USER=system
DB_PASSWORD=password
DB_NAME=promenade_dev
DB_SSLMODE=disable
DB_MAX_OPEN_CONNS=25
DB_MAX_IDLE_CONNS=5
# JWT Configuration
# Generate with: openssl rand -base64 32
JWT_SECRET=your-super-secret-key-change-in-production
JWT_ACCESS_TTL_MINUTES=15 # 15 minutes
JWT_REFRESH_TTL_HOURS=168 # 7 days (168 hours)
# Swagger Configuration
SWAGGER_ENABLED=true
SWAGGER_HOST=localhost:8081
# Rate Limiting
RATE_LIMIT_RPS=100 # Requests per second
RATE_LIMIT_BURST=200 # Burst capacity
# Event Bus Configuration
BUS_WORKER_POOL_SIZE=10 # Concurrent workers
BUS_BUFFER_SIZE=1000 # Message buffer capacity
BUS_RETRY_ATTEMPTS=3 # Max retry attempts on failure
BUS_RETRY_DELAY=1s # Delay between retries
# Email Configuration
EMAIL_FROM_NAME=Promenade Team
[email protected]
Setup Local Environment
# Copy example file
cp .env.local.example .env.local
# Edit with your settings
vim .env.local
# Values in .env.local override all other env files
Loading Priority
.env.local (highest priority)
↓
.env.development
↓
.env (base defaults)
Testing
Promenade features a comprehensive testing infrastructure with isolated test database and helper utilities.
Test Statistics
- 388 Total Tests — 100% passing [+]
- 183 Unit Tests (entity validation, domain logic)
- Country, Currency, Session, UserContact, UserPost, UserProfile, User entities
- Permission, Role, RBAC system validation
- Business logic, status transitions, timestamps
- 91 Integration Tests (with real PostgreSQL)
- BaseRepository: 7 tests (transactions, executor pattern)
- Auth: 10 tests (sessions, token management)
- Countries & Currencies: 4 tests (CRUD operations)
- User Management: 33 tests (users, profiles, contacts)
- Content: 37 tests (posts, comments, likes, replies)
- 114 Smoke Tests (end-to-end critical flows)
- Auth: 8 scenarios (registration → login → sessions → logout)
- Country/Currency: 12 scenarios (complete CRUD operations)
- User Management: 35 scenarios (profiles, contacts, posts)
- Content: 31 scenarios (posts, comments, likes)
- RBAC: 41 scenarios (permissions, roles, assignments, wildcards)
- 183 Unit Tests (entity validation, domain logic)
- Test Database — PostgreSQL 16 on port 5433 (isolated from dev DB)
- Test Execution — ~45 seconds for full suite (5s unit + 36s integration + 4s smoke)
-
CoverageUnit TestsIntegration TestsSmoke TestsTotal
Country/Currency 18 4 12 34 Session/Auth 9 10 8 27 UserContact 14 13 11 38 UserPost 42 30 12 84 PostComment 0 22 13 35 UserProfile 31 13 12 56 User/RBAC 64 9 0 73 Permission/Role 31 16 41 88 CommentLikes 0 1 5 6 BaseRepository 0 7 0 7 Total 183 91 114 388
Note: Smoke tests provide end-to-end verification of critical user flows with real database operations. Tests include table-driven tests with multiple scenarios per function.
Running Tests
# Quick test — all tests with auto DB setup
make test # Run unit + integration tests (274 tests)
# Individual test suites
make test-unit # Unit tests only (no database, 183 tests)
make test-integration # Integration tests (real PostgreSQL, 91 tests)
make test-smoke # Smoke tests (~4.5s, critical flows)
make test-coverage # Generate HTML coverage report
make test-watch # Watch mode (re-run on file changes)
# Test database management
make test-db-start # Start test PostgreSQL (port 5433)
make test-db-stop # Stop test database
Test Infrastructure
The project includes production-grade test helpers:
// test/helpers/database.go
testDB := helpers.SetupTestDB(t) // Connect to test database
defer testDB.Close()
defer testDB.CleanupTables(t) // Clean all tables after test
// test/helpers/fixtures.go
user := helpers.UserFixture() // Standard active user
customUser := helpers.UserFixture(func(u *entity.User) {
u.Email = “[email protected]” // Override fields
})
session := helpers.SessionFixture(user.ID) // Active session
expiredSession := helpers.ExpiredSessionFixture(user.ID)
Test Examples
Repository Integration Test:
func TestUserRepository_Create(t *testing.T) {
t.Run("creates user successfully", func(t *testing.T) {
testDB := helpers.SetupTestDB(t)
defer testDB.Close()
defer testDB.CleanupTables(t)
repo := postgres.NewUserRepository(testDB.DB)
user := helpers.UserFixture()
err := repo.Create(context.Background(), user)
require.NoError(t, err)
assert.NotEqual(t, uuid.Nil, user.ID)
})
}
* Smoke Tests
Production-ready end-to-end smoke tests verify critical user flows with real database operations. These tests ensure core functionality works correctly in integration.
Test Suite (test/smoke/):
Test FileScenariosCoverage
| auth_smoke_test.go | 8 | Registration, login, GetMe, refresh, logout, sessions, duplicate validation |
| country_currency_smoke_test.go | 12 | Country & Currency CRUD (create, read, update, delete, list, code lookup) |
| comment_likes_smoke_test.go | 5 | Like/unlike comments, pagination, deleted comments, performance (100 checks) |
| rbac_smoke_test.go | 28 | Permissions, roles, user assignments, wildcards, expiration, RBAC checks |
| rbac_integration_smoke_test.go | 13 | Real-world RBAC: moderator ban, admin feature, creator restrictions, cross-checks |
| post_comment_smoke_test.go | 13 | Comment CRUD, threading, replies, nested replies, pagination, soft delete, auth |
| user_profile_smoke_test.go | 12 | Profile CRUD, privacy, verification, ban/unban, views, last seen, list, search |
| user_post_smoke_test.go | 12 | Post CRUD, draft/publish, featured, schedule, views, soft delete, list, search |
| user_contact_smoke_test.go | 11 | Contact CRUD (email, phone, telegram), primary, verification, visibility, delete |
| Total | 114 | All tests passing [+] (9 test files, production-grade coverage) |
Running Smoke Tests:
# Run all smoke tests (recommended — includes DB setup)
make test-smoke
# Or run manually with go test
go test -v -count=1 ./test/smoke
# Run specific smoke test
go test -v ./test/smoke -run TestCommentLikes_SmokeTest
# Skip in short mode
go test -short ./test/smoke # Smoke tests are skipped
Example Smoke Tests:
// RBAC Smoke Test — comprehensive permission and role management
func TestRBAC_SmokeTest(t *testing.T) {
// Tests: permissions, roles, user assignments, wildcards, expiration
t.Run("[+] Create_custom_permissions“, func(t *testing.T) {
perm, err := permUC.CreatePermission(ctx, “invoices”, “read”, “Can read invoices”)
require.NoError(t, err)
})
t.Run("[+] Assign_permissions_to_role", func(t *testing.T) {
err := roleUC.SyncRolePermissions(ctx, roleID, permIDs)
require.NoError(t, err)
})
t.Run("[+] Check_wildcard_permission“, func(t *testing.T) {
hasPerm, err := roleUC.HasPermission(ctx, userID, “reports:create”)
assert.True(t, hasPerm, “via wildcard reports:*”)
})
}
// Comment Likes Smoke Test — like/unlike flow with performance check
func TestCommentLikes_SmokeTest(t *testing.T) {
t.Run("[+] Basic_like_flow", func(t *testing.T) {
// Like comment → verify count → unlike → verify again
})
t.Run("* Performance“, func(t *testing.T) {
// Execute 100 HasUserLiked queries and measure performance
})
}
Key Features:
- [+] Real database integration (PostgreSQL on port 5433)
- [+] Isolated test data with automatic cleanup
- [+] Critical path verification (create → retrieve → update → delete)
- [+] Performance benchmarks included (100 permission checks in <500ms)
- [+] Fast execution (~4 seconds for all 114 scenarios across 9 test files)
- [+] 100% passing rate with comprehensive coverage
- [+] Idempotent tests with cleanup at start and end (CleanupTables)
See TESTING_GUIDE.md for comprehensive testing documentation.
API Endpoints & Manual Testing
Endpoint Coverage
The API provides 79 REST endpoints across 8 modules with comprehensive functionality.
ModuleEndpointsTestedStatusDescription
| Auth | 11 | 6 | [+] 55% | Registration, login, logout, refresh, session management |
| Profiles | 13 | 4 | [+] 31% | User profiles with privacy settings and moderation |
| Contacts | 9 | 3 | [+] 33% | User contact management (email, phone, social) |
| Posts | 18 | 5 | [!] 28% | Blog posts with publishing, scheduling, engagement |
| Comments | 9 | 7 | [+] 56% | Threaded comments with likes and moderation |
| Countries | 9 | 4 | [+] 44% | Country management with currency relationships |
| Currencies | 9 | 7 | [+] 78% | Currency management with country relationships |
| Health | 1 | 1 | [+] 100% | Service health check |
| TOTAL | 79 | 37 | 47% | Core functionality fully operational |
Authentication Example
# 1. Register
curl -X POST http://localhost:8081/api/v1/auth/register \
-H “Content-Type: application/json” \
-d ’{"email":"[email protected]","name":"John Doe","password":"SecurePass123!“}’
# 2. Login (get tokens)
curl -X POST http://localhost:8081/api/v1/auth/login \
-H “Content-Type: application/json” \
-d ’{"email":"[email protected]","password":"SecurePass123!“}’
# Response: {“access_token”: “...”, “refresh_token”: “...”}
# 3. Use access token for protected endpoints
curl http://localhost:8081/api/v1/auth/me \
-H “Authorization: Bearer ”
# 4. Refresh when access token expires
curl -X POST http://localhost:8081/api/v1/auth/refresh \
-H “Content-Type: application/json” \
-d ’{"refresh_token":“"}’
# 5. Logout (invalidate refresh token)
curl -X POST http://localhost:8081/api/v1/auth/logout \
-H “Content-Type: application/json” \
-d ’{"refresh_token":“"}’
RBAC Example
# 1. List all permissions (requires permissions:read)
curl http://localhost:8081/api/v1/permissions \
-H “Authorization: Bearer ”
# 2. Create custom permission (requires permissions:create)
curl -X POST http://localhost:8081/api/v1/permissions \
-H “Authorization: Bearer ” \
-H “Content-Type: application/json” \
-d ’{"resource":"articles","action":"publish","description":"Publish articles“}’
# 3. Create new role (requires roles:create)
curl -X POST http://localhost:8081/api/v1/roles \
-H “Authorization: Bearer ” \
-H “Content-Type: application/json” \
-d ’{"name":"content_manager","display_name":"Content Manager","description":"Can manage all content“}’
# 4. Add permissions to role (requires roles:update)
curl -X POST http://localhost:8081/api/v1/roles//permissions \
-H “Authorization: Bearer ” \
-H “Content-Type: application/json” \
-d ’{"permission_id":“"}’
# 5. Assign role to user (requires roles:assign)
curl -X POST http://localhost:8081/api/v1/roles//users/ \
-H “Authorization: Bearer ” \
-H “Content-Type: application/json” \
-d ’{"expires_at":"2025-12-31T23:59:59Z"}’ # Optional expiration
# 6. Check user’s roles
curl http://localhost:8081/api/v1/users//roles \
-H “Authorization: Bearer ”
# 7. Use wildcard permissions for superadmin
# System role “superadmin” has permission “*:*” which grants all access
Swagger Documentation
Interactive API documentation available at:
4 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарівИ никому видимо ты со своим опытом и структурным мышлением не нужен! Не переживай!Не ты один такой ! Больше смайликов в коде -:)
Точно як ти і сказав — у війну майже розбіглися усі...
Вітаю, Олександре! Я Володимир, CEO foodtech/retailtech проєкту. Шукаємо CTO/Head of Tech як техспівзасновника (equity). Якщо цікаво — перейдемо в приват. Які варіанти контакту зручні?
я відкритий до діалогу — telegram @Basilex