Знову підсумовую свій досвід у 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):

Docker (port 8080):


Swagger UI Authentication

How to authenticate in Swagger UI (browser):

  1. Login via /api/v1/auth/login endpoint:{
    “email”: “[email protected]”,
    “password”: “passw0rd”
    }
  2. Copy the access_token from response (e.g., eyJhbGciOiJIUzI1NiIs...)
  3. Click the “Authorize” button 🔓 (green lock icon at the top right)
  4. In the “Value” field, enter:Bearer eyJhbGciOiJIUzI1NiIs...

    ⚠️ IMPORTANT: You must type the word Bearer, then a space, then your token

  5. Click “Authorize” and close the dialog
  6. 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

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

DomainBusiness entities & repository interfacesNone (independent)
Use CaseApplication business rulesDomain only
AdapterHTTP handlers, DTOs, mappersUse Case, Domain
InfrastructureDatabase, config, external servicesDomain 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:

  1. Not set (nil) — field was never provided
  2. Empty (&"") — field was explicitly cleared
  3. 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-blockingPublish() returns immediately — user doesn’t wait for side effects
DecouplingUse cases don’t know about email/analytics — just publish events
ScalabilityEasy to add new subscribers without changing existing code
Evolution PathStart with in-memory bus → Redis Pub/Sub → NATS → Kafka
TestingMock bus for tests, no actual email/analytics calls
Async ProcessingWorker 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!

Hi {{.Name}},

Your account has been successfully created.

Start Exploring →


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

  1. Events are immutable — Never modify event after publishing
  2. Events are facts — Past tense naming (UserRegistered, not RegisterUser)
  3. Idempotent handlers — Handle duplicate events gracefully
  4. Don’t fail operations on event errors — Log and continue
  5. Keep events small — Only essential data (use IDs, not full objects)
  6. Version events — Add EventVersion field for schema evolution
  7. Monitor dead letters — Track and retry failed events
  8. 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


Configuration


Environment Files


The project uses a hierarchical environment configuration:

FilePurposeCommitted?Priority

.envBase defaults[+] YesLowest
.env.developmentDevelopment settings[+] YesMedium
.env.localPersonal overrides[X] NoHighest
.env.productionProduction secrets[X] NoProduction


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)
  • 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/Currency1841234
    Session/Auth910827
    UserContact14131138
    UserPost42301284
    PostComment0221335
    UserProfile31131256
    User/RBAC649073
    Permission/Role31164188
    CommentLikes0156
    BaseRepository0707
    Total18391114388

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.go8Registration, login, GetMe, refresh, logout, sessions, duplicate validation
country_currency_smoke_test.go12Country & Currency CRUD (create, read, update, delete, list, code lookup)
comment_likes_smoke_test.go5Like/unlike comments, pagination, deleted comments, performance (100 checks)
rbac_smoke_test.go28Permissions, roles, user assignments, wildcards, expiration, RBAC checks
rbac_integration_smoke_test.go13Real-world RBAC: moderator ban, admin feature, creator restrictions, cross-checks
post_comment_smoke_test.go13Comment CRUD, threading, replies, nested replies, pagination, soft delete, auth
user_profile_smoke_test.go12Profile CRUD, privacy, verification, ban/unban, views, last seen, list, search
user_post_smoke_test.go12Post CRUD, draft/publish, featured, schedule, views, soft delete, list, search
user_contact_smoke_test.go11Contact CRUD (email, phone, telegram), primary, verification, visibility, delete
Total114All 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

Auth116[+] 55%Registration, login, logout, refresh, session management
Profiles134[+] 31%User profiles with privacy settings and moderation
Contacts93[+] 33%User contact management (email, phone, social)
Posts185[!] 28%Blog posts with publishing, scheduling, engagement
Comments97[+] 56%Threaded comments with likes and moderation
Countries94[+] 44%Country management with currency relationships
Currencies97[+] 78%Currency management with country relationships
Health11[+] 100%Service health check
TOTAL793747%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:

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

👍ПодобаєтьсяСподобалось4
До обраногоВ обраному5
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

И никому видимо ты со своим опытом и структурным мышлением не нужен! Не переживай!Не ты один такой ! Больше смайликов в коде -:)

Точно як ти і сказав — у війну майже розбіглися усі...

Вітаю, Олександре! Я Володимир, CEO foodtech/retailtech проєкту. Шукаємо CTO/Head of Tech як техспівзасновника (equity). Якщо цікаво — перейдемо в приват. Які варіанти контакту зручні?

Підписатись на коментарі