84 KiB
MaplePress Backend - Developer Guide
Last Updated: 2025-10-30
This guide provides everything you need to understand and contribute to the MaplePress Backend codebase.
Table of Contents
- Overview
- Architecture Overview
- Module Organization
- Key Architectural Decisions
- Authentication & Authorization
- Multi-Tenancy Implementation
- Working with Cassandra
- Meilisearch Integration
- Usage-Based Billing
- Scheduled Jobs
- Rate Limiting Architecture
- Adding New Features
- Code Patterns & Conventions
- Testing Guidelines
- Common Pitfalls
Overview
MaplePress Backend is a multi-tenant SaaS platform built with Go that provides cloud-powered services for WordPress sites. The primary feature is cloud-based full-text search using Meilisearch, with future expansion planned for file uploads, metrics, and analytics.
Key Features
- WordPress Plugin Integration - API key authentication (Stripe-style) for WordPress plugins
- Full-Text Search - Meilisearch-powered search with per-site indexes
- Multi-Tenant Architecture - Shared tables with tenant isolation via partition keys
- Usage-Based Billing - Track all usage for billing (no quotas or limits)
- Rate Limiting - Generous anti-abuse limits (10K req/hour per API key)
- Focused Use Cases - Single-responsibility use cases for composable workflows
- Clean Architecture - Clear layer separation with dependency inversion
Technology Stack
| Category | Technology | Purpose |
|---|---|---|
| Language | Go 1.24.4 | Backend language |
| DI Framework | Google Wire | Compile-time dependency injection |
| CLI Framework | Cobra | Command-line interface |
| Database | Cassandra 3.11 | Primary data store (3-node cluster) |
| Cache | Redis | Session storage, distributed locks |
| Search | Meilisearch | Full-text search engine |
| Object Storage | AWS S3 / S3-compatible | File storage (optional) |
| Mailgun | Transactional emails | |
| Logger | Uber Zap | Structured logging |
| HTTP Router | net/http (stdlib) | HTTP server (Go 1.22+) |
| JWT | golang-jwt/jwt v5 | JSON Web Tokens |
| Password | golang.org/x/crypto | Argon2id hashing |
| Migrations | golang-migrate/migrate v4 | Database migrations |
| Cron | robfig/cron v3 | Scheduled jobs |
| UUID | google/uuid, gocql UUID | Unique identifiers |
Architecture Overview
MaplePress follows Clean Architecture with 5 distinct layers:
┌─────────────────────────────────────────────────────┐
│ Interface Layer (HTTP Handlers, DTOs, Middleware) │
│ internal/interface/http/ │
└──────────────────────┬──────────────────────────────┘
│
┌──────────────────────▼──────────────────────────────┐
│ Service Layer (Use Case Orchestration) │
│ internal/service/ │
└──────────────────────┬──────────────────────────────┘
│
┌──────────────────────▼──────────────────────────────┐
│ Use Case Layer (Focused Business Logic) │
│ internal/usecase/ │
└──────────────────────┬──────────────────────────────┘
│
┌──────────────────────▼──────────────────────────────┐
│ Repository Layer (Data Access) │
│ internal/repo/, internal/repository/ │
│ ├── models/ (Explicit Cassandra Table Models) │
└──────────────────────┬──────────────────────────────┘
│
┌──────────────────────▼──────────────────────────────┐
│ Domain Layer (Entities & Interfaces) │
│ internal/domain/ │
└─────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│ Infrastructure (pkg/) │
│ ├── logger/ (Zap logger) │
│ ├── storage/ (Database, Cache, S3) │
│ ├── search/ (Meilisearch client) │
│ ├── security/ (JWT, API keys, passwords) │
│ ├── cache/ (Two-tier caching) │
│ ├── distributedmutex/ (Redis-based locks) │
│ ├── emailer/ (Mailgun) │
│ ├── httperror/ (HTTP error handling) │
│ └── httpresponse/ (HTTP response helpers) │
└─────────────────────────────────────────────────────┘
Directory Structure
maplepress-backend/
├── app/ # Dependency injection (Wire)
├── cmd/ # CLI commands (Cobra)
│ ├── daemon/ # Start HTTP server
│ ├── migrate/ # Database migrations
│ └── version/ # Version command
├── config/ # Configuration management
│ └── constants/ # Constants (session keys, etc.)
├── internal/ # Core application code
│ ├── domain/ # Domain entities & repository interfaces
│ ├── repo/ # Repository implementations (new pattern)
│ ├── repository/ # Repository implementations (old pattern)
│ ├── usecase/ # Focused use cases (single responsibility)
│ ├── service/ # Orchestration services
│ ├── interface/http/ # HTTP handlers, DTOs, middleware
│ ├── http/middleware/ # Additional middleware
│ └── scheduler/ # Cron jobs (quota reset)
├── pkg/ # Shared packages
│ ├── cache/ # Two-tier caching (Redis + Cassandra)
│ ├── distributedmutex/ # Distributed locks (Redis)
│ ├── emailer/ # Mailgun email service
│ ├── httperror/ # HTTP error utilities
│ ├── httpresponse/ # HTTP response helpers
│ ├── logger/ # Zap structured logging
│ ├── search/ # Meilisearch client
│ ├── security/ # JWT, API keys, password hashing
│ └── storage/ # Database, cache, S3 storage
├── migrations/ # Cassandra CQL migrations
└── static/ # Static files (blacklists, etc.)
Dependency Rule
Dependencies point INWARD: Interface → Service → Use Case → Repository → Domain
- Outer layers depend on inner layers
- Inner layers never know about outer layers
- Domain layer has NO dependencies on other layers
- Repository interfaces defined in domain layer
- Implementations in repository/repo layer
Layer Responsibilities
Domain Layer (internal/domain/)
- Pure business entities
- Repository interfaces (contracts)
- Domain errors
- Business validation logic
- No external dependencies
Repository Layer (internal/repo/, internal/repository/)
- Data access implementations
- Cassandra table models
- Query implementations
- Batched writes for consistency
- Two patterns: old (repository/) and new (repo/)
Use Case Layer (internal/usecase/)
- Highly focused, single-responsibility operations
- Composable building blocks
- Input/output structs (IDOs)
- Business orchestration at operation level
- Example: ValidatePlanTierUseCase, GenerateAPIKeyUseCase
Service Layer (internal/service/)
- Orchestrates multiple use cases into workflows
- Transaction boundaries
- Cross-cutting concerns
- Example: SyncPagesService orchestrates 7+ use cases
Interface Layer (internal/interface/http/)
- HTTP handlers
- DTOs (Data Transfer Objects)
- Middleware (JWT, API key, tenant extraction)
- Request/response transformation
- HTTP routing
Module Organization
MaplePress is organized into domain modules, each with its own entities, repositories, use cases, and services.
Core Modules
Tenant Module (internal/domain/tenant/)
Purpose: Top-level organization/customer entity in multi-tenant architecture
Entity: Tenant
- Name, Slug, Status (active/inactive/suspended)
- Root entity - not owned by any other tenant
Repository: TenantRepository
- Create, GetByID, GetBySlug, Update, Delete
- No tenant isolation (tenants are root entities)
Database Tables:
tenants_by_id- Primary lookuptenants_by_slug- Unique slug lookuptenants_by_status- Status filtering
User Module (internal/domain/user/)
Purpose: User accounts belonging to tenants
Entity: User
- Email, Name, PasswordHash, Role, TenantID
- Every user belongs to exactly one tenant
- Role-based access (admin, user)
Repository: UserRepository
- CRUD operations with tenant isolation
- GetByEmail for authentication
- Argon2id password hashing
Database Tables:
users_by_id- Primary lookup (partition: tenant_id, id)users_by_email- Email lookupusers_by_date- List by creation date
Site Module (internal/domain/site/)
Purpose: WordPress sites registered in the system
Entity: Site - Comprehensive site management with usage tracking and authentication
Fields:
- Identity: ID, TenantID, Domain, SiteURL
- Authentication: APIKeyHash, APIKeyPrefix, APIKeyLastFour
- Status: Status (pending/active/inactive/suspended/archived), IsVerified, VerificationToken
- Search: SearchIndexName, TotalPagesIndexed, LastIndexedAt
- Usage Tracking (for billing):
- StorageUsedBytes - Cumulative storage consumption
- SearchRequestsCount - Monthly search API calls (resets monthly)
- MonthlyPagesIndexed - Pages indexed this month (resets monthly)
- LastResetAt - Billing cycle reset timestamp
Repository: SiteRepository (pattern in internal/repo/)
- Multi-table Cassandra pattern (4 tables for different access patterns)
- Batched writes for consistency
- Usage tracking updates
Database Tables:
sites_by_id- Primary table (partition: tenant_id, clustering: id)sites_by_tenant- List view (partition: tenant_id, clustering: created_at)sites_by_domain- Domain uniqueness (partition: domain)sites_by_apikey- Fast authentication (partition: api_key_hash)
Usage-Based Billing Model:
- ✅ No plan tiers - All sites have same feature access
- ✅ No quota limits - Services never reject due to usage
- ✅ Usage tracking only - Track consumption for billing
- ✅ Monthly resets - Counters reset for billing cycles
- ✅ Rate limiting - Anti-abuse only (10K requests/hour per API key)
Page Module (internal/domain/page/)
Purpose: WordPress pages/posts indexed in the system
Entity: Page
- SiteID, PageID (WordPress ID), TenantID
- Title, Content (HTML stripped), Excerpt, URL
- Status (publish/draft/trash), PostType (page/post), Author
- PublishedAt, ModifiedAt, IndexedAt
- MeilisearchDocID (for search integration)
Repository: PageRepository (new pattern in internal/repo/page/)
- Page CRUD operations
- Batch operations for sync
Database Table:
pages_by_site(partition: site_id, clustering: page_id)
Session Module (internal/domain/session/)
Purpose: User authentication sessions
Entity: Session
- SessionID, UserID, UserUUID, UserEmail, UserName, UserRole, TenantID, ExpiresAt
- JWT-based session management
- Tenant context for isolation
Storage: Redis cache (not persistent in Cassandra)
- TTL: 60 minutes (configurable)
- Auto-expiration
HTTP Routes by Module
Public Routes (no auth):
GET /health # Health check
POST /api/v1/register # User registration
POST /api/v1/login # User login
Authenticated Routes (JWT auth):
POST /api/v1/hello # Test endpoint
GET /api/v1/me # Get current user
# Tenant management
POST /api/v1/tenants # Create tenant
GET /api/v1/tenants/{id} # Get tenant by ID
GET /api/v1/tenants/slug/{slug} # Get tenant by slug
Tenant-Scoped Routes (JWT + Tenant context):
# User management
POST /api/v1/users # Create user
GET /api/v1/users/{id} # Get user
# Site management
POST /api/v1/sites # Create site
GET /api/v1/sites # List sites
GET /api/v1/sites/{id} # Get site
DELETE /api/v1/sites/{id} # Delete site
POST /api/v1/sites/{id}/rotate-api-key # Rotate API key
WordPress Plugin API (API Key auth):
# Site status
GET /api/v1/plugin/status # Get site status & quotas
# Page sync
POST /api/v1/plugin/pages/sync # Sync pages to search
GET /api/v1/plugin/pages/status # Get sync status
GET /api/v1/plugin/pages/{page_id} # Get page by ID
# Page search
POST /api/v1/plugin/pages/search # Search pages
# Page deletion
DELETE /api/v1/plugin/pages # Delete specific pages
DELETE /api/v1/plugin/pages/all # Delete all pages
Key Architectural Decisions
ADR-001: Explicit Cassandra Table Models ✅
Decision: Use separate Go structs for each Cassandra table.
Why?
- Makes query-first data modeling visible in code
- Self-documenting (struct names explain purpose)
- Type-safe with compile-time checking
- Easy to maintain and refactor
Example:
// Clear which table we're using
var userByID models.UserByID // → users_by_id table
var userByEmail models.UserByEmail // → users_by_email table
var userByDate models.UserByDate // → users_by_date table
Structure:
internal/repository/user/
├── models/
│ ├── user_by_id.go # UserByID struct → users_by_id table
│ ├── user_by_email.go # UserByEmail struct → users_by_email table
│ └── user_by_date.go # UserByDate struct → users_by_date table
├── impl.go # Repository struct
├── create.go # Create operations
├── get.go # Get operations
├── update.go # Update operations
├── delete.go # Delete operations
└── schema.cql # Cassandra schema
ADR-002: Multi-Tenancy with Shared Tables ✅
Decision: Shared tables with tenant_id in partition keys (Option 3A).
Why?
- Scales to 10,000+ tenants
- Cost-effective ($0.10-1/tenant/month vs $500+/tenant/month for dedicated clusters)
- Simple operations (one schema, one migration)
- Industry-proven (Slack, GitHub, Stripe use this)
- Partition key ensures physical isolation
Implementation:
-- tenant_id is part of the partition key
CREATE TABLE users_by_id (
tenant_id UUID, -- Multi-tenant isolation
id UUID,
email TEXT,
name TEXT,
created_at TIMESTAMP,
updated_at TIMESTAMP,
PRIMARY KEY ((tenant_id, id)) -- Composite partition key
);
Security: All queries MUST include tenant_id - cannot query without it (would require table scan, which fails).
ADR-003: Wire for Dependency Injection ✅
Decision: Use Google Wire for compile-time dependency injection.
Why?
- No runtime reflection overhead
- Errors caught at compile time
- Easy to debug (generated code is readable)
- Clear dependency graph
Location: app/wire.go (not in main package to avoid import cycles)
ADR-004: Cobra for CLI ✅
Decision: Use Cobra for command-line interface.
Commands:
maplepress-backend daemon- Start servermaplepress-backend version- Show version
ADR-005: DTOs in Separate Folder ✅
Decision: DTOs (Data Transfer Objects) live in internal/interface/http/dto/[entity]/
Why?
- API contracts are first-class citizens
- Clear separation from internal data structures
- Easy to version and evolve
ADR-006: Use Case I/O = IDOs ✅
Decision: Use case input/output structs serve as IDOs (Internal Data Objects).
Why?
- Avoids unnecessary abstraction
- Use cases already define clear contracts
- Simpler codebase
ADR-007: Focused Use Cases ✅
Decision: Break use cases into highly focused, single-responsibility operations.
Why?
- Each use case has one clear purpose
- Easy to test in isolation
- Composable building blocks
- Clear dependencies
- Service layer orchestrates multiple use cases
Example:
// Traditional (monolithic):
CreateSiteUseCase // Does everything
// Refactored (focused):
ValidatePlanTierUseCase
ValidateDomainUseCase
GenerateAPIKeyUseCase
GenerateVerificationTokenUseCase
CreateSiteEntityUseCase
SaveSiteToRepoUseCase
ADR-008: Dual Repository Pattern ✅
Decision: Coexist old (repository/) and new (repo/) repository patterns during migration.
Why?
- Allows gradual migration to simplified pattern
- New pattern (
repo/) used for Site and Page modules - Old pattern (
repository/) used for User and Tenant modules - Shows evolutionary refinement
Authentication & Authorization
MaplePress uses dual authentication: JWT for users and API keys for WordPress plugins.
User Authentication (JWT)
Registration Flow:
- Validate input (email, password, tenant name/slug)
- Check tenant slug uniqueness
- Hash password (Argon2id with secure defaults)
- Create tenant entity
- Create user entity
- Save both to database
- Create session + generate JWT
- Return JWT token
Login Flow:
- Get user by email
- Verify password (Argon2id)
- Create session (Redis, 60min TTL)
- Generate JWT token
- Return token + user profile
JWT Claims:
- SessionID (UUID)
- Issued at, Expires at
- Secret:
APP_JWT_SECRET(configurable)
Middleware: JWTMiddleware
- Validates JWT tokens (
Authorization: JWT <token>) - Populates context:
SessionID,UserID,UserEmail,UserRole,TenantID - Used for dashboard/admin routes
WordPress Plugin Authentication (API Key)
API Key Format:
- Production:
live_sk_+ 40 random chars - Development:
test_sk_+ 40 random chars - Total length: 48 characters
Generation Process:
- Generate 30 random bytes
- Encode to base64url
- Clean special chars, trim to 40 chars
- Prefix with
live_sk_ortest_sk_
Storage:
- Hash: SHA-256 (stored in Cassandra
sites_by_apikeytable) - Display: Prefix (first 13 chars) + Last 4 chars (e.g.,
live_sk_abc...xyz1) - Full key: Shown only once at creation (never retrievable)
Authentication Flow:
- Extract API key from
Authorization: Bearerheader - Hash API key (SHA-256)
- Query
sites_by_apikeytable by hash - Validate site status (active/pending allowed)
- Populate context with site details
Middleware: APIKeyMiddleware
- Validates API keys
- Populates context:
SiteID,SiteTenantID,SiteDomain,SitePlanTier - Used for WordPress plugin routes
API Key Rotation:
/api/v1/sites/{id}/rotate-api-keyendpoint- Generates new API key
- Old key immediately invalid
- Return new key (shown only once)
Meilisearch Integration
MaplePress uses Meilisearch for fast, typo-tolerant full-text search of WordPress content.
Architecture
Index Pattern: One index per WordPress site
- Index name:
site_{site_id}(e.g.,site_123e4567-e89b-12d3-a456-426614174000) - Isolated search data per site
- Independent index configuration
Search Document Structure:
type PageDocument struct {
ID string `json:"id"` // Meilisearch document ID
SiteID string `json:"site_id"` // Site UUID
TenantID string `json:"tenant_id"` // Tenant UUID
PageID int64 `json:"page_id"` // WordPress page ID
Title string `json:"title"` // Page title
Content string `json:"content"` // HTML-stripped content
Excerpt string `json:"excerpt"` // Page excerpt
URL string `json:"url"` // Page URL
Status string `json:"status"` // publish/draft/trash
PostType string `json:"post_type"` // page/post
Author string `json:"author"` // Author name
PublishedAt int64 `json:"published_at"` // Unix timestamp
ModifiedAt int64 `json:"modified_at"` // Unix timestamp
}
Page Sync Workflow
Endpoint: POST /api/v1/plugin/pages/sync
Process:
- Authenticate API key
- Validate site status and quotas
- Check monthly indexing quota
- Ensure Meilisearch index exists
- For each page:
- Strip HTML from content
- Create page entity
- Upsert to Cassandra
pages_by_sitetable - Add to bulk index batch
- Bulk index documents to Meilisearch
- Update site quotas (pages indexed, last indexed timestamp)
- Return sync summary
Quota Enforcement:
- Monthly indexing quota checked before sync
- Exceeding quota returns 403 Forbidden
- Quota resets monthly via cron job
Search Workflow
Endpoint: POST /api/v1/plugin/pages/search
Process:
- Authenticate API key
- Validate site status and search quota
- Check monthly search quota
- Execute Meilisearch query
- Increment search request counter
- Return search results
Search Features:
- Typo tolerance (configurable)
- Faceted search
- Filtering by status, post type, author
- Custom ranking rules
- Pagination support
Quota Enforcement:
- Monthly search quota checked before search
- Exceeding quota returns 403 Forbidden
- Quota resets monthly via cron job
Index Management
Index Creation:
- Automatic on first page sync
- Configured with searchable attributes: title, content, excerpt
- Filterable attributes: status, post_type, author
- Sortable attributes: published_at, modified_at
Index Deletion:
- When site is deleted
- Cascades to all indexed pages
Usage-Based Billing
MaplePress uses a usage-based billing model with no quota limits or plan tiers. All usage is tracked for billing purposes.
Usage Tracking (For Billing)
Cumulative Metrics (Never Reset)
- Storage:
StorageUsedBytes- Total bytes stored across all pages - Total Pages:
TotalPagesIndexed- All-time page count
Monthly Metrics (Reset Monthly for Billing Cycles)
- Searches:
SearchRequestsCount- Search API requests this month - Indexing:
MonthlyPagesIndexed- Pages indexed this month - Reset Tracking:
LastResetAt- When the monthly counters were last reset
No Quota Enforcement
✅ Page Sync - Always Allowed:
// ValidateSiteUseCase - No quota checks
site, err := uc.siteRepo.GetByID(ctx, tenantID, siteID)
if site.RequiresVerification() && !site.IsVerified {
return nil, domainsite.ErrSiteNotVerified
}
// Process pages without limits
✅ Search - Always Allowed:
// Search service - No quota checks
result, err := uc.searchClient.Search(siteID.String(), searchReq)
// Always increment usage counter for billing
site.IncrementSearchCount()
uc.siteRepo.UpdateUsage(ctx, site)
Usage Updates
Simple Atomic Updates:
// Update usage tracking (no locks needed for counters)
site.MonthlyPagesIndexed += pagesIndexed
site.TotalPagesIndexed += pagesIndexed
site.SearchRequestsCount += 1
// Save to database
repo.UpdateUsage(ctx, site)
Optimized Usage Tracking:
- Use
UpdateUsageUseCasefor usage-only updates - Avoids full site entity update
- Batched write to all 4 site tables
Rate Limiting (Anti-Abuse Only)
MaplePress has generous rate limits to prevent abuse, not to enforce quotas:
Plugin API Endpoints:
- Limit: 10,000 requests/hour per API key
- Purpose: Anti-abuse only (prevent infinite loops, bugs)
- Supports: High-volume WordPress sites (240K requests/day)
- Middleware:
RateLimitMiddlewaresininternal/http/middleware/
Scheduled Jobs
MaplePress uses robfig/cron v3 for scheduled background tasks.
Monthly Usage Reset Scheduler
Location: internal/scheduler/quota_reset.go (legacy name)
Schedule: 0 0 1 * * (1st of month at midnight UTC) - configurable
Purpose: Reset monthly usage counters for billing cycles
Process:
- Get all sites paginated (
GetAllSitesForUsageReset) - For each site:
- Reset
SearchRequestsCountto 0 - Reset
MonthlyPagesIndexedto 0 - Set
LastResetAtto current timestamp - Update site in database
- Reset
- Log summary:
- Total sites processed
- Total sites reset
- Failed resets (if any)
Configuration:
# .env
SCHEDULER_QUOTA_RESET_ENABLED=true
SCHEDULER_QUOTA_RESET_SCHEDULE="0 0 1 * *"
Use Case: ResetMonthlyUsageUseCase (renamed from ResetMonthlyQuotasUseCase)
- Encapsulates reset logic for billing cycles
- Handles errors gracefully
- Logs progress
- No quota enforcement, only counter resets for accurate billing
Production Considerations:
- Runs in single backend instance (use distributed lock if multiple instances)
- Idempotent (safe to run multiple times)
- Paginated processing for large site counts
- Monitor logs for failures
- Critical for accurate billing - must run reliably
Rate Limiting Architecture
Overview
MaplePress implements a Four-Tier Rate Limiting Architecture to satisfy OWASP ASVS 4.2.2 requirements for anti-automation controls while supporting high-volume legitimate traffic for the core WordPress Plugin API business.
CRITICAL REQUIREMENT: Every new API endpoint MUST belong to one of the four rate limiters for OWASP compliance.
OWASP Compliance
OWASP ASVS 4.2.2: "Verify that anti-automation controls are effective at mitigating breached credential testing, brute force, and account lockout attacks."
CWE Coverage:
- CWE-307: Improper Restriction of Excessive Authentication Attempts → Registration + Login rate limiters
- CWE-770: Allocation of Resources Without Limits or Throttling → Generic + Plugin API rate limiters
- CWE-348: Use of Less Trusted Source (IP validation) → clientip.Extractor with trusted proxy validation
- CWE-532: Insertion of Sensitive Information into Log File → Email/slug hashing for Redis keys
The Four Rate Limiters
┌─────────────────────────────────────────────────────────────────┐
│ Four-Tier Rate Limiting Architecture │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. Registration Rate Limiter (CWE-307) │
│ - Scope: POST /api/v1/register │
│ - Strategy: IP-based │
│ - Default: 5 requests/hour per IP │
│ - Purpose: Prevent account farming, bot signups │
│ │
│ 2. Login Rate Limiter (CWE-307) │
│ - Scope: POST /api/v1/login │
│ - Strategy: Dual (IP + account with lockout) │
│ - Defaults: 10 attempts/15min (IP), 10 failed/30min lockout │
│ - Purpose: Prevent brute force, credential stuffing │
│ │
│ 3. Generic CRUD Rate Limiter (CWE-770) │
│ - Scope: Authenticated CRUD endpoints │
│ - Strategy: User-based (JWT user ID) │
│ - Default: 100 requests/hour per user │
│ - Purpose: Prevent resource exhaustion │
│ - Endpoints: tenants, users, sites, admin, /me, /hello │
│ │
│ 4. Plugin API Rate Limiter (CWE-770) │
│ - Scope: WordPress Plugin API endpoints │
│ - Strategy: Site-based (API key → site_id) │
│ - Default: 1000 requests/hour per site │
│ - Purpose: Core business protection with high throughput │
│ - Endpoints: /api/v1/plugin/* (7 endpoints) │
│ │
└─────────────────────────────────────────────────────────────────┘
1. Registration Rate Limiter
Configuration:
RATELIMIT_REGISTRATION_ENABLED=true
RATELIMIT_REGISTRATION_MAX_REQUESTS=5
RATELIMIT_REGISTRATION_WINDOW=1h
When to Use:
- User registration endpoints
- Public account creation APIs
- IP-based protection needed
Implementation:
// Apply to registration route
if s.config.RateLimit.RegistrationEnabled {
mux.HandleFunc("POST /api/v1/register",
s.rateLimitMiddlewares.Registration.Handler(
http.HandlerFunc(s.registerHandler.Handle),
).ServeHTTP)
}
2. Login Rate Limiter
Configuration:
RATELIMIT_LOGIN_ENABLED=true
RATELIMIT_LOGIN_MAX_ATTEMPTS_PER_IP=10
RATELIMIT_LOGIN_IP_WINDOW=15m
RATELIMIT_LOGIN_MAX_FAILED_ATTEMPTS_PER_ACCOUNT=10
RATELIMIT_LOGIN_ACCOUNT_LOCKOUT_DURATION=30m
When to Use:
- User authentication endpoints
- Any endpoint accepting credentials
- Dual protection: IP-based + account lockout
Implementation:
// Login handler handles rate limiting internally
// Uses specialized LoginRateLimiter with account lockout
func (h *LoginHandler) Handle(w http.ResponseWriter, r *http.Request) {
// Extract IP and email
// Check rate limits (IP + account)
// Handle login logic
}
3. Generic CRUD Rate Limiter
Configuration:
RATELIMIT_GENERIC_ENABLED=true
RATELIMIT_GENERIC_MAX_REQUESTS=100
RATELIMIT_GENERIC_WINDOW=1h
When to Use:
- Authenticated CRUD endpoints (JWT)
- Tenant management routes
- User management routes
- Site management routes
- Admin routes
- Dashboard/profile routes
Implementation:
// Helper method for JWT + Generic rate limiting
func (s *Server) applyAuthOnlyWithGenericRateLimit(handler http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Chain: JWT validation → Auth check → Generic rate limit (user-based) → Handler
s.jwtMiddleware.Handler(
s.jwtMiddleware.RequireAuth(
s.rateLimitMiddlewares.Generic.HandlerWithUserKey(
http.HandlerFunc(handler),
),
),
).ServeHTTP(w, r)
}
}
// Apply to routes
if s.config.RateLimit.GenericEnabled {
mux.HandleFunc("GET /api/v1/me", s.applyAuthOnlyWithGenericRateLimit(s.getMeHandler.Handle))
mux.HandleFunc("POST /api/v1/tenants", s.applyAuthOnlyWithGenericRateLimit(s.createTenantHandler.Handle))
}
4. Plugin API Rate Limiter
Configuration:
RATELIMIT_PLUGIN_API_ENABLED=true
RATELIMIT_PLUGIN_API_MAX_REQUESTS=1000
RATELIMIT_PLUGIN_API_WINDOW=1h
When to Use:
- WordPress Plugin API endpoints
- API key authenticated routes
- High-volume business-critical endpoints
- Site-based protection needed
Implementation:
// Helper method for API Key + Plugin rate limiting
func (s *Server) applyAPIKeyAuthWithPluginRateLimit(handler http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Chain: API key validation → Require API key → Plugin rate limit (site-based) → Handler
s.apikeyMiddleware.Handler(
s.apikeyMiddleware.RequireAPIKey(
s.rateLimitMiddlewares.PluginAPI.HandlerWithSiteKey(
http.HandlerFunc(handler),
),
),
).ServeHTTP(w, r)
}
}
// Apply to routes
if s.config.RateLimit.PluginAPIEnabled {
mux.HandleFunc("POST /api/v1/plugin/pages/sync", s.applyAPIKeyAuthWithPluginRateLimit(s.syncPagesHandler.Handle))
mux.HandleFunc("POST /api/v1/plugin/pages/search", s.applyAPIKeyAuthWithPluginRateLimit(s.searchPagesHandler.Handle))
}
Adding Rate Limiting to New Endpoints
Step 1: Identify the Endpoint Type
Ask yourself:
- Is this a public registration endpoint? → Registration Rate Limiter
- Is this a login/authentication endpoint? → Login Rate Limiter
- Is this a JWT-authenticated CRUD endpoint? → Generic Rate Limiter
- Is this a WordPress Plugin API endpoint (API key auth)? → Plugin API Rate Limiter
Step 2: Apply the Appropriate Rate Limiter
Example 1: New authenticated CRUD endpoint
// New endpoint: Update user profile
if s.config.RateLimit.GenericEnabled {
mux.HandleFunc("PUT /api/v1/users/{id}",
s.applyAuthAndTenantWithGenericRateLimit(s.updateUserHandler.Handle))
} else {
mux.HandleFunc("PUT /api/v1/users/{id}",
s.applyAuthAndTenant(s.updateUserHandler.Handle))
}
Example 2: New WordPress Plugin API endpoint
// New endpoint: Get plugin statistics
if s.config.RateLimit.PluginAPIEnabled {
mux.HandleFunc("GET /api/v1/plugin/stats",
s.applyAPIKeyAuthWithPluginRateLimit(s.pluginStatsHandler.Handle))
} else {
mux.HandleFunc("GET /api/v1/plugin/stats",
s.applyAPIKeyAuth(s.pluginStatsHandler.Handle))
}
Step 3: Test Rate Limiting
# Test Generic Rate Limiter (100/hour limit)
TOKEN="your_jwt_token"
for i in {1..150}; do
curl http://localhost:8000/api/v1/me \
-H "Authorization: Bearer $TOKEN"
done
# Expected: First 100 succeed, rest return 429
# Test Plugin API Rate Limiter (1000/hour limit)
API_KEY="your_api_key"
for i in {1..1100}; do
curl http://localhost:8000/api/v1/plugin/status \
-H "Authorization: Bearer $API_KEY"
done
# Expected: First 1000 succeed, rest return 429
Rate Limiting Strategies
IP-Based (Registration)
// Redis key: ratelimit:registration:<ip_address>
// Extracts client IP using clientip.Extractor
// Sliding window algorithm
Dual (Login)
// Redis keys:
// - login_rl:ip:<ip>
// - login_rl:account:<email_hash>:attempts
// - login_rl:account:<email_hash>:locked
// IP-based + account-based with lockout
// Specialized implementation in login handler
User-Based (Generic CRUD)
// Redis key: ratelimit:generic:user:<user_id>
// Extracts user ID from JWT context (constants.SessionUserID)
// Fallback to IP if user ID not available
func (m *RateLimitMiddleware) HandlerWithUserKey(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var key string
if userID, ok := r.Context().Value(constants.SessionUserID).(uint64); ok {
key = fmt.Sprintf("user:%d", userID)
} else {
// Fallback to IP-based rate limiting
key = fmt.Sprintf("ip:%s", m.ipExtractor.Extract(r))
}
// Check rate limit...
})
}
Site-Based (Plugin API)
// Redis key: ratelimit:plugin:site:<site_id>
// Extracts site ID from API key context (constants.SiteID)
// Fallback to IP if site ID not available
func (m *RateLimitMiddleware) HandlerWithSiteKey(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var key string
if siteID, ok := r.Context().Value(constants.SiteID).(string); ok && siteID != "" {
key = fmt.Sprintf("site:%s", siteID)
} else {
// Fallback to IP-based rate limiting
key = fmt.Sprintf("ip:%s", m.ipExtractor.Extract(r))
}
// Check rate limit...
})
}
Configuration
Environment Variables:
# ============================================================================
# 1. Registration Rate Limiter (CWE-307: Account Creation Protection)
# ============================================================================
RATELIMIT_REGISTRATION_ENABLED=true
RATELIMIT_REGISTRATION_MAX_REQUESTS=5
RATELIMIT_REGISTRATION_WINDOW=1h
# ============================================================================
# 2. Login Rate Limiter (CWE-307: Brute Force Protection)
# ============================================================================
RATELIMIT_LOGIN_ENABLED=true
RATELIMIT_LOGIN_MAX_ATTEMPTS_PER_IP=10
RATELIMIT_LOGIN_IP_WINDOW=15m
RATELIMIT_LOGIN_MAX_FAILED_ATTEMPTS_PER_ACCOUNT=10
RATELIMIT_LOGIN_ACCOUNT_LOCKOUT_DURATION=30m
# ============================================================================
# 3. Generic CRUD Endpoints Rate Limiter (CWE-770: Resource Exhaustion Protection)
# ============================================================================
RATELIMIT_GENERIC_ENABLED=true
RATELIMIT_GENERIC_MAX_REQUESTS=100
RATELIMIT_GENERIC_WINDOW=1h
# ============================================================================
# 4. Plugin API Rate Limiter (CWE-770: DoS Prevention for Core Business)
# ============================================================================
RATELIMIT_PLUGIN_API_ENABLED=true
RATELIMIT_PLUGIN_API_MAX_REQUESTS=1000
RATELIMIT_PLUGIN_API_WINDOW=1h
Recommended Production Values:
Registration (most strict):
- Small sites: 5 requests/hour per IP ✅ (default)
- Medium sites: 10 requests/hour per IP
- Large sites: 20 requests/hour per IP
Login (moderate):
- Default: 10 attempts/15min per IP ✅
- Stricter: 5 attempts/10min per IP
- More lenient: 15 attempts/30min per IP
Generic CRUD (lenient):
- Default: 100 requests/hour per user ✅
- Heavy usage: 200 requests/hour per user
- Very heavy: 500 requests/hour per user
Plugin API (very lenient - core business):
- Default: 1000 requests/hour per site ✅
- Enterprise tier: 5000 requests/hour per site
- Premium tier: 10000 requests/hour per site
Endpoint Coverage
Total API Endpoints: 25 Endpoints with Rate Limiting: 23 (92%) OWASP-Critical Endpoints Protected: 23/23 (100%)
Registration Rate Limiter (1 endpoint)
POST /api/v1/register- IP-based, 5/hour
Login Rate Limiter (1 endpoint)
POST /api/v1/login- Dual (IP + account lockout), 10/15min per IP
Generic CRUD Rate Limiter (15 endpoints)
User-based, 100/hour per user:
POST /api/v1/hello,GET /api/v1/mePOST /api/v1/tenants,GET /api/v1/tenants/{id},GET /api/v1/tenants/slug/{slug}POST /api/v1/users,GET /api/v1/users/{id}POST /api/v1/sites,GET /api/v1/sites,GET /api/v1/sites/{id},DELETE /api/v1/sites/{id},POST /api/v1/sites/{id}/rotate-api-keyPOST /api/v1/admin/unlock-account,GET /api/v1/admin/account-status
Plugin API Rate Limiter (7 endpoints)
Site-based, 1000/hour per site:
GET /api/v1/plugin/statusPOST /api/v1/plugin/pages/syncPOST /api/v1/plugin/pages/searchDELETE /api/v1/plugin/pagesDELETE /api/v1/plugin/pages/allGET /api/v1/plugin/pages/statusGET /api/v1/plugin/pages/{page_id}
No Rate Limiting (2 endpoints)
GET /health- Health check endpoint (no rate limit needed)POST /api/v1/refresh- Token refresh (no rate limit needed, short-lived)
Fail-Open Design
All rate limiters implement fail-open design:
- If Redis is down, requests are allowed
- Error is logged, but request proceeds
- Prioritizes availability over strict security
- Appropriate for business-critical endpoints
// Check rate limit
allowed, err := m.rateLimiter.Allow(r.Context(), key)
if err != nil {
// Log error but allow request (fail-open)
m.logger.Error("rate limiter error",
zap.String("key", key),
zap.Error(err))
}
if !allowed {
// Rate limit exceeded
w.Header().Set("Retry-After", "3600") // 1 hour
httperror.TooManyRequests(w, "Rate limit exceeded. Please try again later.")
return
}
Monitoring and Troubleshooting
Check Rate Limiter Initialization:
# View logs for rate limiter initialization
docker logs mapleopentech_backend | grep "rate"
# Expected output:
# Registration rate limiter: enabled=true, max_requests=5, window=1h0m0s
# Login rate limiter: enabled=true, max_attempts_ip=10, ip_window=15m0s
# Generic rate limiter: enabled=true, max_requests=100, window=1h0m0s
# Plugin API rate limiter: enabled=true, max_requests=1000, window=1h0m0s
Check Redis Keys:
# Connect to Redis
docker exec -it mapleopentech_redis redis-cli
# List rate limit keys
KEYS ratelimit:*
KEYS login_rl:*
# Get rate limit value for specific key
GET ratelimit:registration:<ip_address>
GET ratelimit:generic:user:<user_id>
GET ratelimit:plugin:site:<site_id>
Disable Rate Limiter Temporarily:
# Disable specific rate limiter in .env
RATELIMIT_GENERIC_ENABLED=false
RATELIMIT_PLUGIN_API_ENABLED=false
# Restart backend
task end && task dev
Common Pitfalls
❌ Forgetting to apply rate limiting to new endpoints
Wrong:
// New endpoint without rate limiting (OWASP violation!)
mux.HandleFunc("POST /api/v1/posts", s.createPostHandler.Handle)
Correct:
// New endpoint with appropriate rate limiting
if s.config.RateLimit.GenericEnabled {
mux.HandleFunc("POST /api/v1/posts",
s.applyAuthAndTenantWithGenericRateLimit(s.createPostHandler.Handle))
} else {
mux.HandleFunc("POST /api/v1/posts",
s.applyAuthAndTenant(s.createPostHandler.Handle))
}
❌ Using wrong rate limiter for endpoint type
Wrong:
// Using Generic rate limiter for WordPress Plugin API
mux.HandleFunc("POST /api/v1/plugin/pages/sync",
s.applyAuthOnlyWithGenericRateLimit(s.syncPagesHandler.Handle))
Correct:
// Using Plugin API rate limiter for WordPress Plugin API
mux.HandleFunc("POST /api/v1/plugin/pages/sync",
s.applyAPIKeyAuthWithPluginRateLimit(s.syncPagesHandler.Handle))
❌ Missing configuration check
Wrong:
// Always applying rate limiting (doesn't respect config)
mux.HandleFunc("GET /api/v1/me",
s.applyAuthOnlyWithGenericRateLimit(s.getMeHandler.Handle))
Correct:
// Respecting configuration flag
if s.config.RateLimit.GenericEnabled {
mux.HandleFunc("GET /api/v1/me",
s.applyAuthOnlyWithGenericRateLimit(s.getMeHandler.Handle))
} else {
mux.HandleFunc("GET /api/v1/me",
s.applyAuthOnly(s.getMeHandler.Handle))
}
Summary
Key Takeaways:
- ✅ Every new API endpoint MUST belong to one of the four rate limiters
- ✅ Choose the appropriate rate limiter based on endpoint type
- ✅ Always wrap routes with configuration checks
- ✅ Use helper methods for consistent middleware chaining
- ✅ Test rate limiting after adding new endpoints
- ✅ Monitor Redis for rate limit key usage
- ✅ OWASP ASVS 4.2.2 compliance is mandatory
Files to Modify When Adding New Endpoints:
internal/interface/http/server.go- Add route with rate limiting- Test your endpoint with rate limit testing script
Multi-Tenancy Implementation
Overview
MaplePress uses shared tables with tenant isolation via partition keys. This means:
- All tenants share the same Cassandra cluster and tables
- Each row has a
tenant_idfield in its partition key - Cassandra physically separates data by partition key (tenant A's data on different nodes than tenant B)
- ALL repository methods require
tenantIDparameter
Tenant Extraction
Tenant ID is extracted from HTTP headers by middleware:
File: internal/interface/http/middleware/tenant.go
// For development: get from X-Tenant-ID header
// TODO: In production, extract from JWT token
tenantID := r.Header.Get("X-Tenant-ID")
// Store in context
ctx := context.WithValue(r.Context(), TenantIDKey, tenantID)
Production TODO: Replace header extraction with JWT token validation.
Repository Pattern
ALL repository methods require tenantID:
type Repository interface {
Create(ctx context.Context, tenantID string, user *User) error
GetByID(ctx context.Context, tenantID string, id string) (*User, error)
GetByEmail(ctx context.Context, tenantID string, email string) (*User, error)
Update(ctx context.Context, tenantID string, user *User) error
Delete(ctx context.Context, tenantID string, id string) error
}
Table Models with tenant_id
File: internal/repository/user/models/user_by_id.go
type UserByID struct {
TenantID string `db:"tenant_id"` // Multi-tenant isolation
ID string `db:"id"`
Email string `db:"email"`
Name string `db:"name"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
// Conversion functions require tenantID
func FromUser(tenantID string, u *user.User) *UserByID {
return &UserByID{
TenantID: tenantID, // CRITICAL: Set tenant ID
ID: u.ID,
Email: u.Email,
// ...
}
}
Queries with tenant_id
// CORRECT: Query with tenant_id
query := `SELECT tenant_id, id, email, name, created_at, updated_at
FROM users_by_id
WHERE tenant_id = ? AND id = ?`
// WRONG: Query without tenant_id (would fail - requires table scan)
query := `SELECT id, email FROM users_by_id WHERE id = ?`
Working with Cassandra
Query-First Data Modeling
Cassandra requires designing tables for specific query patterns, not normalizing data.
Rule: One query pattern = One table
Example:
Query 1: Get user by ID → users_by_id table
Query 2: Get user by email → users_by_email table
Query 3: List users by date → users_by_date table
Primary Keys
Partition Key: Determines which node stores the data Clustering Key: Sorts data within a partition
-- Partition key: (tenant_id, id)
-- No clustering key
PRIMARY KEY ((tenant_id, id))
-- Partition key: (tenant_id, created_date)
-- Clustering key: id
PRIMARY KEY ((tenant_id, created_date), id)
Batched Writes
When writing to multiple tables (denormalization), use batched writes for consistency:
File: internal/repository/user/create.go
func (r *repository) Create(ctx context.Context, tenantID string, u *domainuser.User) error {
// Convert to table models
userByID := models.FromUser(tenantID, u)
userByEmail := models.FromUserByEmail(tenantID, u)
userByDate := models.FromUserByDate(tenantID, u)
// Create batch (atomic write to all 3 tables)
batch := r.session.NewBatch(gocql.LoggedBatch)
// Add all writes to batch
batch.Query(`INSERT INTO users_by_id (...) VALUES (...)`, ...)
batch.Query(`INSERT INTO users_by_email (...) VALUES (...)`, ...)
batch.Query(`INSERT INTO users_by_date (...) VALUES (...)`, ...)
// Execute atomically
return r.session.ExecuteBatch(batch)
}
Rule: ALWAYS use batched writes for create/update/delete to maintain consistency across denormalized tables.
Consistency Levels
MaplePress uses QUORUM consistency by default (defined in config):
QUORUM = (Replication Factor / 2) + 1
With RF=3: QUORUM = 2 nodes must acknowledge
This balances consistency and availability.
Adding New Features
Quick Reference Checklist
When adding a new entity (e.g., "Post"):
- 1. Define domain entity:
internal/domain/post/entity.go - 2. Define repository interface:
internal/domain/post/repository.go - 3. Design Cassandra tables (one per query pattern)
- 4. Create table models:
internal/repository/post/models/ - 5. Implement repository:
internal/repository/post/ - 6. Create schema file:
internal/repository/post/schema.cql - 7. Implement use cases:
internal/usecase/post/ - 8. Create service:
internal/service/post_service.go - 9. Create DTOs:
internal/interface/http/dto/post/ - 10. Implement handlers:
internal/interface/http/handler/post/ - 11. Wire everything:
app/wire.go - 12. Add routes:
internal/interface/http/server.go
Step-by-Step: Adding "Post" Entity
Step 1: Domain Layer
File: internal/domain/post/entity.go
package post
import (
"errors"
"time"
)
var (
ErrTitleRequired = errors.New("title is required")
ErrContentRequired = errors.New("content is required")
)
type Post struct {
ID string
TenantID string // Not in domain, but needed for multi-tenancy
AuthorID string
Title string
Content string
CreatedAt time.Time
UpdatedAt time.Time
}
func (p *Post) Validate() error {
if p.Title == "" {
return ErrTitleRequired
}
if p.Content == "" {
return ErrContentRequired
}
return nil
}
File: internal/domain/post/repository.go
package post
import "context"
// Repository defines data access for posts
// All methods require tenantID for multi-tenant isolation
type Repository interface {
Create(ctx context.Context, tenantID string, post *Post) error
GetByID(ctx context.Context, tenantID string, id string) (*Post, error)
Update(ctx context.Context, tenantID string, post *Post) error
Delete(ctx context.Context, tenantID string, id string) error
ListByAuthor(ctx context.Context, tenantID string, authorID string) ([]*Post, error)
}
Step 2: Design Cassandra Tables
Identify query patterns:
- Get post by ID
- List posts by author
File: internal/repository/post/schema.cql
-- posts_by_id: Get post by ID
CREATE TABLE IF NOT EXISTS posts_by_id (
tenant_id UUID,
id UUID,
author_id UUID,
title TEXT,
content TEXT,
created_at TIMESTAMP,
updated_at TIMESTAMP,
PRIMARY KEY ((tenant_id, id))
);
-- posts_by_author: List posts by author
CREATE TABLE IF NOT EXISTS posts_by_author (
tenant_id UUID,
author_id UUID,
id UUID,
title TEXT,
content TEXT,
created_at TIMESTAMP,
updated_at TIMESTAMP,
PRIMARY KEY ((tenant_id, author_id), created_at, id)
) WITH CLUSTERING ORDER BY (created_at DESC, id ASC);
Step 3: Create Table Models
File: internal/repository/post/models/post_by_id.go
package models
import (
"time"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/post"
)
// PostByID represents the posts_by_id table
// Query pattern: Get post by ID
type PostByID struct {
TenantID string `db:"tenant_id"`
ID string `db:"id"`
AuthorID string `db:"author_id"`
Title string `db:"title"`
Content string `db:"content"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
func (p *PostByID) ToPost() *post.Post {
return &post.Post{
ID: p.ID,
AuthorID: p.AuthorID,
Title: p.Title,
Content: p.Content,
CreatedAt: p.CreatedAt,
UpdatedAt: p.UpdatedAt,
}
}
func FromPost(tenantID string, p *post.Post) *PostByID {
return &PostByID{
TenantID: tenantID,
ID: p.ID,
AuthorID: p.AuthorID,
Title: p.Title,
Content: p.Content,
CreatedAt: p.CreatedAt,
UpdatedAt: p.UpdatedAt,
}
}
File: internal/repository/post/models/post_by_author.go
package models
import (
"time"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/post"
)
// PostByAuthor represents the posts_by_author table
// Query pattern: List posts by author
type PostByAuthor struct {
TenantID string `db:"tenant_id"`
AuthorID string `db:"author_id"`
ID string `db:"id"`
Title string `db:"title"`
Content string `db:"content"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
func (p *PostByAuthor) ToPost() *post.Post {
return &post.Post{
ID: p.ID,
AuthorID: p.AuthorID,
Title: p.Title,
Content: p.Content,
CreatedAt: p.CreatedAt,
UpdatedAt: p.UpdatedAt,
}
}
func FromPostByAuthor(tenantID string, p *post.Post) *PostByAuthor {
return &PostByAuthor{
TenantID: tenantID,
AuthorID: p.AuthorID,
ID: p.ID,
Title: p.Title,
Content: p.Content,
CreatedAt: p.CreatedAt,
UpdatedAt: p.UpdatedAt,
}
}
Step 4: Implement Repository
File: internal/repository/post/impl.go
package post
import (
"github.com/gocql/gocql"
"go.uber.org/zap"
domainpost "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/post"
)
type repository struct {
session *gocql.Session
logger *zap.Logger
}
func ProvideRepository(session *gocql.Session, logger *zap.Logger) domainpost.Repository {
return &repository{
session: session,
logger: logger,
}
}
File: internal/repository/post/create.go
package post
import (
"context"
"github.com/gocql/gocql"
domainpost "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/post"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/repository/post/models"
)
func (r *repository) Create(ctx context.Context, tenantID string, p *domainpost.Post) error {
// Convert to table models
postByID := models.FromPost(tenantID, p)
postByAuthor := models.FromPostByAuthor(tenantID, p)
// Batched write for consistency
batch := r.session.NewBatch(gocql.LoggedBatch)
batch.Query(`INSERT INTO posts_by_id (tenant_id, id, author_id, title, content, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
postByID.TenantID, postByID.ID, postByID.AuthorID, postByID.Title,
postByID.Content, postByID.CreatedAt, postByID.UpdatedAt)
batch.Query(`INSERT INTO posts_by_author (tenant_id, author_id, id, title, content, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
postByAuthor.TenantID, postByAuthor.AuthorID, postByAuthor.ID, postByAuthor.Title,
postByAuthor.Content, postByAuthor.CreatedAt, postByAuthor.UpdatedAt)
return r.session.ExecuteBatch(batch)
}
Step 5: Implement Use Cases
File: internal/usecase/post/create.go
package post
import (
"context"
"time"
"github.com/google/uuid"
domainpost "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/post"
)
type CreatePostInput struct {
AuthorID string
Title string
Content string
}
type CreatePostOutput struct {
ID string
Title string
CreatedAt time.Time
}
type CreatePostUseCase struct {
repo domainpost.Repository
}
func ProvideCreatePostUseCase(repo domainpost.Repository) *CreatePostUseCase {
return &CreatePostUseCase{repo: repo}
}
func (uc *CreatePostUseCase) Execute(ctx context.Context, tenantID string, input *CreatePostInput) (*CreatePostOutput, error) {
now := time.Now()
post := &domainpost.Post{
ID: uuid.New().String(),
AuthorID: input.AuthorID,
Title: input.Title,
Content: input.Content,
CreatedAt: now,
UpdatedAt: now,
}
if err := post.Validate(); err != nil {
return nil, err
}
if err := uc.repo.Create(ctx, tenantID, post); err != nil {
return nil, err
}
return &CreatePostOutput{
ID: post.ID,
Title: post.Title,
CreatedAt: post.CreatedAt,
}, nil
}
Step 6: Create Service
File: internal/service/post_service.go
package service
import (
"context"
"go.uber.org/zap"
postupc "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/post"
)
type PostService struct {
createUC *postupc.CreatePostUseCase
logger *zap.Logger
}
func ProvidePostService(
createUC *postupc.CreatePostUseCase,
logger *zap.Logger,
) *PostService {
return &PostService{
createUC: createUC,
logger: logger,
}
}
func (s *PostService) CreatePost(ctx context.Context, tenantID string, input *postupc.CreatePostInput) (*postupc.CreatePostOutput, error) {
return s.createUC.Execute(ctx, tenantID, input)
}
Step 7: Create DTOs
File: internal/interface/http/dto/post/create_dto.go
package post
import "time"
type CreateRequest struct {
AuthorID string `json:"author_id"`
Title string `json:"title"`
Content string `json:"content"`
}
type CreateResponse struct {
ID string `json:"id"`
Title string `json:"title"`
CreatedAt time.Time `json:"created_at"`
}
Step 8: Create Handler
File: internal/interface/http/handler/post/create_handler.go
package post
import (
"encoding/json"
"net/http"
"go.uber.org/zap"
postdto "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/interface/http/dto/post"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/interface/http/middleware"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service"
postupc "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/post"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httperror"
)
type CreateHandler struct {
service *service.PostService
logger *zap.Logger
}
func ProvideCreateHandler(service *service.PostService, logger *zap.Logger) *CreateHandler {
return &CreateHandler{
service: service,
logger: logger,
}
}
func (h *CreateHandler) Handle(w http.ResponseWriter, r *http.Request) {
tenantID, err := middleware.GetTenantID(r.Context())
if err != nil {
httperror.Unauthorized(w, "missing tenant")
return
}
var req postdto.CreateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httperror.BadRequest(w, "invalid request body")
return
}
input := &postupc.CreatePostInput{
AuthorID: req.AuthorID,
Title: req.Title,
Content: req.Content,
}
output, err := h.service.CreatePost(r.Context(), tenantID, input)
if err != nil {
h.logger.Error("failed to create post", zap.Error(err))
httperror.InternalServerError(w, "failed to create post")
return
}
response := postdto.CreateResponse{
ID: output.ID,
Title: output.Title,
CreatedAt: output.CreatedAt,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(response)
}
Step 9: Wire Dependencies
File: app/wire.go
// Add to imports
postrepo "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/repository/post"
postupc "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/post"
posthandler "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/interface/http/handler/post"
// Add to wire.Build()
wire.Build(
// ... existing providers ...
// Post repository
postrepo.ProvideRepository,
// Post use cases
postupc.ProvideCreatePostUseCase,
// Post service
service.ProvidePostService,
// Post handlers
posthandler.ProvideCreateHandler,
// ... rest ...
)
Update: internal/service/post_service.go provider function
Update: internal/interface/http/server.go struct to include post handlers
Step 10: Add Routes
File: internal/interface/http/server.go
// Add to Server struct
type Server struct {
// ... existing ...
createPostHandler *posthandler.CreateHandler
}
// Update ProvideServer
func ProvideServer(
cfg *config.Config,
logger *zap.Logger,
healthHandler *healthcheck.HealthCheckHandler,
createUserHandler *userhandler.CreateHandler,
getUserHandler *userhandler.GetHandler,
createPostHandler *posthandler.CreateHandler, // NEW
) *Server {
return &Server{
// ... existing ...
createPostHandler: createPostHandler,
}
}
// Add to registerRoutes
func (s *Server) registerRoutes(mux *http.ServeMux) {
// ... existing routes ...
mux.HandleFunc("POST /api/v1/posts", s.createPostHandler.Handle)
}
Step 11: Test Your Changes
# The dev server auto-rebuilds via CompileDaemon
# Just save your files and check the logs
# Or restart the dev server to see changes
task dev
Code Patterns & Conventions
File Organization
One operation per file:
internal/repository/user/
├── impl.go # Repository struct
├── create.go # Create operation
├── get.go # Get operations (GetByID, GetByEmail)
├── update.go # Update operation
├── delete.go # Delete operation
└── list.go # List operations
Naming Conventions
Repository Methods: Verb + preposition
Create()
GetByID()
GetByEmail()
Update()
Delete()
ListByDate()
ListByAuthor()
Use Case Files: [operation].go
internal/usecase/user/
├── create.go
├── get.go
├── update.go
└── delete.go
DTOs: [operation]_dto.go
internal/interface/http/dto/user/
├── create_dto.go
├── get_dto.go
└── update_dto.go
Import Aliases
Use aliases to avoid conflicts:
import (
userdto "path/to/dto/user"
userusecase "path/to/usecase/user"
userrepo "path/to/repository/user"
domainuser "path/to/domain/user"
)
Error Handling
RFC 9457 (Problem Details for HTTP APIs) ✅
MaplePress implements RFC 9457 (previously RFC 7807) for standardized HTTP error responses. This provides machine-readable, structured error responses that clients can easily parse and display.
Standard: RFC 9457 - Problem Details for HTTP APIs
Implementation Location: pkg/httperror/error.go
Response Structure:
type ProblemDetail struct {
Type string `json:"type"` // URI reference identifying the problem type
Title string `json:"title"` // Short, human-readable summary
Status int `json:"status"` // HTTP status code
Detail string `json:"detail,omitempty"` // Human-readable explanation
Instance string `json:"instance,omitempty"` // URI reference to specific occurrence
Errors map[string][]string `json:"errors,omitempty"` // Validation errors (extension field)
}
Content-Type: All RFC 9457 responses use application/problem+json
Usage - Validation Errors:
// For validation errors with field-specific messages
validationErrors := map[string][]string{
"email": {"Invalid email format"},
"password": {"Field is required", "Password must be at least 8 characters"},
"name": {"Field is required"},
}
httperror.ValidationError(w, validationErrors, "One or more validation errors occurred")
Example Response:
{
"type": "about:blank",
"title": "Validation Error",
"status": 400,
"detail": "One or more validation errors occurred",
"errors": {
"email": ["Invalid email format"],
"password": ["Field is required", "Password must be at least 8 characters"],
"name": ["Field is required"]
}
}
Usage - Simple Errors:
// For simple errors without field-specific details
httperror.ProblemBadRequest(w, "Invalid request body")
httperror.ProblemUnauthorized(w, "Authentication required")
httperror.ProblemForbidden(w, "Access denied")
httperror.ProblemNotFound(w, "User not found")
httperror.ProblemConflict(w, "Email already exists")
httperror.ProblemTooManyRequests(w, "Rate limit exceeded")
httperror.ProblemInternalServerError(w, "Failed to create user")
Available Helper Functions:
// RFC 9457 compliant error responses
httperror.ValidationError(w, errors map[string][]string, detail string)
httperror.ProblemBadRequest(w, detail string)
httperror.ProblemUnauthorized(w, detail string)
httperror.ProblemForbidden(w, detail string)
httperror.ProblemNotFound(w, detail string)
httperror.ProblemConflict(w, detail string)
httperror.ProblemTooManyRequests(w, detail string)
httperror.ProblemInternalServerError(w, detail string)
// Legacy format (backward compatibility)
httperror.BadRequest(w, message string)
httperror.Unauthorized(w, message string)
httperror.NotFound(w, message string)
httperror.InternalServerError(w, message string)
Validation Error Pattern:
When implementing validation in DTOs, return structured errors:
// DTO Validation
type ValidationErrors struct {
Errors map[string][]string
}
func (v *ValidationErrors) Error() string {
// Implement error interface for logging
var messages []string
for field, errs := range v.Errors {
for _, err := range errs {
messages = append(messages, fmt.Sprintf("%s: %s", field, err))
}
}
return fmt.Sprintf("validation errors: %v", messages)
}
// In DTO Validate() method
func (r *RegisterRequest) Validate() error {
validationErrors := make(map[string][]string)
// Collect all validation errors
if err := validateEmail(r.Email); err != nil {
validationErrors["email"] = append(validationErrors["email"], err.Error())
}
if err := validatePassword(r.Password); err != nil {
validationErrors["password"] = append(validationErrors["password"], err.Error())
}
// Return structured errors if any exist
if len(validationErrors) > 0 {
return &ValidationErrors{Errors: validationErrors}
}
return nil
}
// In Handler
if err := req.Validate(); err != nil {
if validationErr, ok := err.(*dto.ValidationErrors); ok {
// Return RFC 9457 validation error
httperror.ValidationError(w, validationErr.Errors, "One or more validation errors occurred")
return
}
// Fallback for non-validation errors
httperror.ProblemBadRequest(w, err.Error())
return
}
Benefits:
- ✅ Standardized error format across all endpoints
- ✅ Machine-readable error responses for frontend parsing
- ✅ Multiple errors returned at once (better UX)
- ✅ Field-specific error mapping for forms
- ✅ Industry-standard format (used by GitHub, Stripe, etc.)
- ✅ Proper Content-Type:
application/problem+json
Legacy Error Handling:
For backward compatibility, legacy error functions are still available but RFC 9457 format is preferred for all new code:
Domain errors: Define in entity files
var (
ErrUserNotFound = errors.New("user not found")
ErrEmailRequired = errors.New("email is required")
)
Repository errors: Wrap with context
if err := query.Scan(...); err != nil {
return nil, fmt.Errorf("failed to get user: %w", err)
}
Handler errors: Use RFC 9457 format (preferred) or legacy format
// Preferred: RFC 9457 format
httperror.ProblemBadRequest(w, "Invalid request body")
httperror.ProblemUnauthorized(w, "Missing tenant")
httperror.ProblemNotFound(w, "User not found")
httperror.ProblemInternalServerError(w, "Failed to create user")
// Legacy format (backward compatibility)
httperror.BadRequest(w, "invalid request body")
httperror.Unauthorized(w, "missing tenant")
httperror.NotFound(w, "user not found")
httperror.InternalServerError(w, "failed to create user")
Logging
CRITICAL: PII Redaction Requirements 🔒
CWE-532: Insertion of Sensitive Information into Log File
MaplePress implements comprehensive PII (Personally Identifiable Information) redaction to comply with GDPR and security best practices. You MUST NEVER log actual emails, IP addresses, or other sensitive data in plaintext.
Prohibited in Logs (NEVER log these directly):
- ❌ Email addresses (plaintext)
- ❌ IP addresses (plaintext)
- ❌ Passwords (even hashed)
- ❌ API keys
- ❌ Session tokens
- ❌ Phone numbers
- ❌ Personal names (in most contexts)
- ❌ Payment information
Required: Use Logger Helper Functions
MaplePress provides secure logging helpers in pkg/logger/:
import (
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/logger"
)
// ✅ CORRECT: Redacted email logging
h.logger.Info("user registered successfully",
zap.String("user_id", output.UserID),
zap.String("tenant_id", output.TenantID),
logger.EmailHash(output.UserEmail), // SHA-256 hash for correlation
logger.SafeEmail("email", output.UserEmail)) // Partial: "te***@ex***.com"
// ✅ CORRECT: Redacted tenant slug logging
h.logger.Warn("tenant slug already exists",
logger.TenantSlugHash(input.TenantSlug), // SHA-256 hash
logger.SafeTenantSlug("slug", input.TenantSlug)) // Partial: "my-***-tenant"
// ❌ WRONG: Plaintext email (NEVER DO THIS!)
h.logger.Info("user registered", zap.String("email", email)) // VIOLATION!
// ❌ WRONG: Plaintext IP address (NEVER DO THIS!)
h.logger.Info("request from", zap.String("ip", ipAddress)) // VIOLATION!
Available Logger Helpers:
File: pkg/logger/sanitizer.go
// EmailHash - Returns SHA-256 hash of email for correlation
// Use for: Tracking user actions across logs without exposing email
logger.EmailHash(email string) zap.Field
// Example: logger.EmailHash("test@example.com")
// Output: "email_hash": "973dfe463ec85785f5f95af5ba3906ee..."
// SafeEmail - Returns partially redacted email
// Use for: Human-readable logs while protecting privacy
logger.SafeEmail(key string, email string) zap.Field
// Example: logger.SafeEmail("email_redacted", "test@example.com")
// Output: "email_redacted": "te***@ex***.com"
// TenantSlugHash - Returns SHA-256 hash of tenant slug
// Use for: Correlation without exposing tenant slug
logger.TenantSlugHash(slug string) zap.Field
// Example: logger.TenantSlugHash("my-company")
// Output: "tenant_slug_hash": "8f3d7e9a..."
// SafeTenantSlug - Returns partially redacted tenant slug
// Use for: Human-readable tenant references
logger.SafeTenantSlug(key string, slug string) zap.Field
// Example: logger.SafeTenantSlug("tenant_slug_redacted", "my-company")
// Output: "tenant_slug_redacted": "my-***-pany"
IP Address Logging:
IP addresses are encrypted before storage and should NEVER be logged in plaintext:
// ✅ CORRECT: Log event without IP
h.logger.Info("user registered successfully",
zap.String("user_id", userID),
zap.String("tenant_id", tenantID))
// IP is encrypted and stored in database, not logged
// ❌ WRONG: Logging plaintext IP
h.logger.Info("registration from IP", zap.String("ip", ipAddress)) // VIOLATION!
Comprehensive Logging Example:
// Success case - redacted PII
h.logger.Info("user registered successfully",
zap.String("user_id", output.UserID), // Safe: UUID
zap.String("tenant_id", output.TenantID), // Safe: UUID
logger.EmailHash(output.UserEmail)) // Safe: Hash
// Error case - redacted PII
h.logger.Error("failed to register user",
zap.Error(err), // Safe: Error message
logger.EmailHash(req.Email), // Safe: Hash for correlation
logger.SafeEmail("email_redacted", req.Email), // Safe: Partial email
logger.TenantSlugHash(req.TenantSlug), // Safe: Hash
logger.SafeTenantSlug("tenant_slug_redacted", req.TenantSlug)) // Safe: Partial
// Security event - no PII needed
h.logger.Warn("rate limit exceeded",
zap.String("path", r.URL.Path), // Safe: Public path
zap.String("method", r.Method)) // Safe: HTTP method
// Note: IP is extracted securely but not logged
What CAN Be Logged Safely:
- ✅ UUIDs (user_id, tenant_id, site_id)
- ✅ Email hashes (SHA-256)
- ✅ Partial emails (redacted)
- ✅ Tenant slug hashes
- ✅ Error messages (without PII)
- ✅ Request paths
- ✅ HTTP methods
- ✅ Status codes
- ✅ Timestamps
- ✅ Operation names
Log Levels:
Use appropriate log levels for different scenarios:
// DEBUG - Development debugging (disabled in production)
h.logger.Debug("processing request",
zap.String("operation", "create_user"))
// INFO - Normal operations
h.logger.Info("user created successfully",
zap.String("user_id", userID))
// WARN - Recoverable issues, validation failures
h.logger.Warn("validation failed",
zap.Error(err))
// ERROR - System errors, failures
h.logger.Error("failed to save to database",
zap.Error(err),
zap.String("operation", "create_user"))
Audit Trail:
For audit purposes, sensitive data is stored encrypted in the database with the entity:
- IP addresses: Encrypted with AES-GCM before storage
- Timestamps: Stored with
CreatedFromIPTimestamp,ModifiedFromIPTimestamp - User actions: Tracked via
CreatedByUserID,ModifiedByUserID
Compliance:
- GDPR Article 5(1)(f): Security of processing
- CWE-532: Insertion of Sensitive Information into Log File
- OWASP Logging Cheat Sheet compliance
Remember:
- Always use
logger.EmailHash()for email correlation - Use
logger.SafeEmail()for human-readable partial emails - Never log IP addresses in plaintext
- Never log passwords (even hashed)
- Never log API keys or tokens
- Use UUIDs for entity references (safe to log)
- When in doubt, don't log it!
Testing Guidelines
Unit Tests
Test domain validation:
func TestUser_Validate(t *testing.T) {
tests := []struct {
name string
user *User
wantErr error
}{
{
name: "valid user",
user: &User{Email: "test@example.com", Name: "Test"},
wantErr: nil,
},
{
name: "missing email",
user: &User{Name: "Test"},
wantErr: ErrEmailRequired,
},
}
// ... run tests
}
Integration Tests
Test repository with real Cassandra (use Docker for tests):
func TestRepository_Create(t *testing.T) {
// Setup: Start Cassandra container
// Create test session
// Apply schema
repo := NewRepository(session, logger)
user := &User{
ID: uuid.New().String(),
Email: "test@example.com",
// ...
}
err := repo.Create(context.Background(), "tenant-123", user)
assert.NoError(t, err)
// Verify in all tables
// ...
}
Handler Tests
Use httptest:
func TestCreateHandler_Handle(t *testing.T) {
// Create mock service
// Create handler
body := `{"email":"test@example.com","name":"Test"}`
req := httptest.NewRequest("POST", "/api/v1/users", strings.NewReader(body))
req.Header.Set("X-Tenant-ID", "tenant-123")
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.Handle(w, req)
assert.Equal(t, http.StatusCreated, w.Code)
// ... verify response
}
Common Pitfalls
❌ Forgetting tenant_id
Wrong:
query := `SELECT id, email FROM users_by_id WHERE id = ?`
Correct:
query := `SELECT tenant_id, id, email FROM users_by_id WHERE tenant_id = ? AND id = ?`
❌ Not using batched writes
Wrong:
// Separate queries - not atomic!
db.Query("INSERT INTO users_by_id ...").Exec()
db.Query("INSERT INTO users_by_email ...").Exec()
Correct:
batch := session.NewBatch(gocql.LoggedBatch)
batch.Query("INSERT INTO users_by_id ...")
batch.Query("INSERT INTO users_by_email ...")
session.ExecuteBatch(batch)
❌ Missing table model conversion
Wrong:
// Using domain entity directly with database
db.Query("INSERT INTO users_by_id ...").Bind(user)
Correct:
// Convert to table model first
userByID := models.FromUser(tenantID, user)
db.Query("INSERT INTO users_by_id ...").Bind(userByID)
❌ Import cycles
Wrong:
main → cmd/daemon → main (CYCLE!)
Correct:
main → cmd/daemon → app (no cycle)
Keep InitializeApplication in app/ package, not main.
❌ Violating dependency rule
Wrong:
// Domain layer importing repository
package domain
import "internal/repository/user" // WRONG!
Correct:
// Domain defines interface, repository implements it
package domain
type Repository interface {
Create(...) error
}
❌ Hardcoded values
Wrong:
tenantID := "default-tenant" // WRONG!
Correct:
tenantID, err := middleware.GetTenantID(ctx)
❌ Not checking quotas before operations
Wrong:
// Sync pages without checking quota
service.SyncPages(ctx, siteID, pages)
Correct:
// Check quota first
if site.MonthlyPagesIndexed >= site.QuotaPages {
return httperror.Forbidden(w, "monthly indexing quota exceeded")
}
service.SyncPages(ctx, siteID, pages)
❌ Forgetting to hash API keys
Wrong:
// Storing plaintext API key
site.APIKey = generatedKey
Correct:
// Hash before storing
hash := security.HashAPIKey(generatedKey)
site.APIKeyHash = hash
site.APIKeyPrefix = generatedKey[:13]
site.APIKeyLastFour = generatedKey[len(generatedKey)-4:]
❌ Not using distributed mutex for quota updates
Wrong:
// Race condition - multiple requests can exceed quota
site.RequestsCount++
repo.Update(site)
Correct:
// Use distributed mutex
mutex.Lock(ctx, fmt.Sprintf("site:%s:quota", siteID))
defer mutex.Unlock()
site.RequestsCount++
repo.UpdateQuotas(site)
❌ Mixing authentication middleware
Wrong:
// Using JWT middleware for plugin routes
mux.Handle("/api/v1/plugin/pages/sync",
jwtMiddleware.RequireAuth(syncHandler))
Correct:
// Use API key middleware for plugin routes
mux.Handle("/api/v1/plugin/pages/sync",
apiKeyMiddleware.RequireAPIKey(syncHandler))
❌ Not deleting Meilisearch index when deleting site
Wrong:
// Only delete from Cassandra
repo.Delete(ctx, tenantID, siteID)
Correct:
// Delete from both Cassandra and Meilisearch
searchClient.DeleteIndex(ctx, site.SearchIndexName)
repo.Delete(ctx, tenantID, siteID)
❌ Forgetting to strip HTML from content
Wrong:
// Indexing raw HTML content
page.Content = wordpressPage.Content
Correct:
// Strip HTML tags before indexing
page.Content = stripHTML(wordpressPage.Content)
Quick Reference
Project Structure
cloud/maplepress-backend/
├── app/ # Application & Wire DI
│ ├── app.go
│ ├── wire.go
│ └── wire_gen.go (generated)
├── cmd/ # CLI commands
│ ├── daemon/
│ ├── root.go
│ └── version/
├── config/ # Configuration
├── internal/
│ ├── domain/ # Entities & interfaces
│ │ └── user/
│ │ ├── entity.go
│ │ └── repository.go
│ ├── repository/ # Data access
│ │ └── user/
│ │ ├── models/
│ │ │ ├── user_by_id.go
│ │ │ ├── user_by_email.go
│ │ │ └── user_by_date.go
│ │ ├── impl.go
│ │ ├── create.go
│ │ ├── get.go
│ │ └── schema.cql
│ ├── usecase/ # Business logic
│ │ └── user/
│ │ ├── create.go
│ │ └── get.go
│ ├── service/ # Orchestration
│ │ └── user_service.go
│ └── interface/http/ # HTTP layer
│ ├── dto/
│ │ └── user/
│ ├── handler/
│ │ ├── healthcheck/
│ │ └── user/
│ ├── middleware/
│ │ ├── tenant.go
│ │ └── logger.go
│ └── server.go
├── pkg/ # Infrastructure
│ ├── logger/
│ ├── storage/
│ │ ├── database/
│ │ └── cache/
│ └── httperror/
├── docker-compose.dev.yml
├── Taskfile.yml
├── .env.sample
└── main.go
Common Commands
# Development
task dev # Start backend (auto-migrate + hot-reload)
task end # Stop backend
task console # Open bash in backend container
# Testing
task test # Run tests
# Code quality
task format # Format code with goimports
task lint # Run golint
task vet # Run go vet
task check # Run format + lint + vet
# Dependencies
task vendor # Download and vendor dependencies
task upgradelib # Update all Go libraries
# Database & Migrations
task db:clear # Clear database
task db:reset # Migration down + up
task migrate:up # Run migrations
task migrate:down # Rollback migrations
task migrate:create # Create new migration
# Manual operations (rarely needed)
task build # Build binary
task wire # Regenerate Wire DI code
# Deployment (DevOps)
task deploy # Build and push production container
task deployqa # Build and push QA container
# Cleanup
task dev-clean # Stop Docker and remove volumes
task clean # Clean build artifacts
API Patterns
Create Resource:
POST /api/v1/users
Headers: X-Tenant-ID, Content-Type: application/json
Body: {"email": "...", "name": "..."}
Response: 201 Created
Get Resource:
GET /api/v1/users/{id}
Headers: X-Tenant-ID
Response: 200 OK
Update Resource:
PUT /api/v1/users/{id}
Headers: X-Tenant-ID, Content-Type: application/json
Body: {"name": "..."}
Response: 200 OK
Delete Resource:
DELETE /api/v1/users/{id}
Headers: X-Tenant-ID
Response: 204 No Content
Getting Help
- Architecture Questions: Review this guide, README.md, and CLAUDE.md
- Cassandra Questions: Check migration files in
migrations/andschema.cqlfiles - API Questions: See handler files in
internal/interface/http/handler/ - Configuration: See
.env.samplefor all available options - Meilisearch: Check
pkg/search/for client implementation - Quota System: Review
internal/domain/site/entity.goand quota use cases - Authentication: See
pkg/security/for JWT and API key utilities - Middleware: Check
internal/interface/http/middleware/for auth patterns
Key Takeaways
MaplePress Backend is a production-ready, multi-tenant SaaS platform that demonstrates:
- Clean Architecture - Clear separation of concerns with dependency inversion
- Focused Use Cases - Single-responsibility operations for composability
- Multi-Table Denormalization - Optimized Cassandra access patterns
- Dual Authentication - JWT for users, API keys for WordPress plugins
- Comprehensive Quota System - Cumulative storage + monthly quotas with cron resets
- Meilisearch Integration - Fast, typo-tolerant full-text search
- Wire Dependency Injection - Compile-time safety and clarity
- Production-Ready Security - Argon2id passwords, SHA-256 API key hashing, distributed locking
Remember:
- Always include
tenant_idin Cassandra queries - Use batched writes for multi-table operations
- Check quotas before resource-intensive operations
- Use distributed mutex for concurrent quota updates
- Hash API keys (SHA-256) before storage
- Strip HTML from content before indexing
- Use appropriate middleware (JWT vs API key)
- Delete Meilisearch indexes when deleting sites
- Follow the dependency rule (dependencies point inward)
- Keep explicit table models for Cassandra
- Break use cases into focused, single-responsibility operations
- Test your code thoroughly
The architecture shows evolutionary refinement with the new focused use case pattern and dual repository approach, demonstrating thoughtful migration toward better patterns while maintaining backward compatibility.
Happy coding! 🚀