Core Concepts
Before diving deep into development, it's essential to understand the core concepts that drive EZ-Console. This guide explains the fundamental principles and patterns you'll use throughout your development.
Controller-Service Pattern
EZ-Console enforces a clear separation between Controllers and Services.
Controllers
Responsibility: HTTP request handling
Controllers are thin layers that:
- Parse and validate HTTP requests
- Extract parameters from URL, query string, or body
- Call service methods for business logic
- Format and return HTTP responses
- Handle HTTP-specific errors
type ProductController struct {
svc server.Service
}
func (c *ProductController) GetProduct(ctx *gin.Context) {
// 1. Extract and validate input
productID := ctx.Param("id")
if productID == "" {
util.RespondWithError(ctx, util.NewErrorMessage("E4001", "Product ID is required"))
return
}
// 2. Call service layer
product, err := c.svc.Product().GetByID(ctx.Request.Context(), productID)
if err != nil {
util.RespondWithError(ctx, util.NewErrorMessage("E5001", "Failed to get product", err))
return
}
// 3. Return response
util.RespondWithSuccess(ctx, http.StatusOK, product)
}
Controllers should NOT:
- ❌ Contain business logic
- ❌ Access the database directly
- ❌ Perform complex calculations
- ❌ Call external services directly
Services
Responsibility: Business logic and data operations
Services are where your business logic lives:
- Implement business rules and validation
- Perform database operations via GORM
- Orchestrate multiple operations
- Handle transactions
- Integrate with external systems
- Return domain models
type ProductService struct {
db *gorm.DB
}
func (s *ProductService) GetByID(ctx context.Context, resourceID string) (*model.Product, error) {
logger := log.GetContextLogger(ctx)
// Business logic and database operation
var product model.Product
err := s.db.Where("resource_id = ?", resourceID).
Preload("Category").
First(&product).Error
if err != nil {
logger.Log("msg", "failed to get product", "err", err)
return nil, err
}
// Business validation
if product.Status == model.ProductStatusDeleted {
return nil, errors.New("product has been deleted")
}
return &product, nil
}
Services should NOT:
- ❌ Access
gin.Contextdirectly - ❌ Format HTTP responses
- ❌ Handle HTTP status codes
- ❌ Parse HTTP requests
Benefits of This Pattern
- Testability: Services can be tested without HTTP layer
- Reusability: Services can be called from multiple controllers
- Maintainability: Clear boundaries make code easier to understand
- Scalability: Business logic can be moved to microservices easily
Request-Response Format
EZ-Console uses a standard response format for all API endpoints.
Success Response (Single Item)
{
"code": "0",
"data": {
"id": "uuid-here",
"name": "Product Name",
"price": 99.99
}
}
Usage in Controller:
util.RespondWithSuccess(ctx, http.StatusOK, product)
Success Response (List with Pagination)
{
"code": "0",
"data": [
{"id": "uuid-1", "name": "Product 1"},
{"id": "uuid-2", "name": "Product 2"}
],
"total": 100,
"current": 1,
"page_size": 10
}
Usage in Controller:
util.RespondWithSuccessList(ctx, http.StatusOK, products, total, current, pageSize)
Error Response
{
"code": "E4001",
"err": "Invalid request parameters"
}
Usage in Controller:
// Simple error message
util.RespondWithError(ctx, util.NewErrorMessage("E4001", "Invalid request"))
// Error with underlying cause
util.RespondWithError(ctx, util.NewErrorMessage("E5001", "Database error", err))
// Wrap existing error
util.RespondWithError(ctx, util.NewError("E5001", err))
Error Code Convention
Error codes follow the pattern: E + HTTP status code + sequence number
Client Errors (4xx):
E4001- Bad Request (400) - Invalid parametersE4012- Unauthorized (401) - Invalid auth tokenE4031- Forbidden (403) - Permission deniedE4041- Not Found (404) - Resource not found
Server Errors (5xx):
E5001- Internal Server Error (500) - General server errorE5002- Database Error (500) - Database operation failedE5003- External Service Error (500) - External API failed
Controller Registration
EZ-Console provides two ways to register controllers.
Using server.RegisterControllers (Recommended)
This is the standard way for application developers:
package controller
import (
"context"
"github.com/gin-gonic/gin"
"github.com/sven-victor/ez-console/server"
)
type ProductController struct {
svc server.Service
}
func (c *ProductController) RegisterRoutes(ctx context.Context, router *gin.RouterGroup) {
products := router.Group("/products")
{
products.GET("", c.ListProducts)
products.GET("/:id", c.GetProduct)
products.POST("", c.CreateProduct)
products.PUT("/:id", c.UpdateProduct)
products.DELETE("/:id", c.DeleteProduct)
}
}
func NewProductController(svc server.Service) *ProductController {
return &ProductController{svc: svc}
}
// Register in init() function
func init() {
server.RegisterControllers(func(ctx context.Context, svc server.Service) server.Controller {
return NewProductController(svc)
})
}
Key Points:
- Controllers receive a
server.Serviceinterface - Must implement
RegisterRoutes(context.Context, *gin.RouterGroup) - Registered in
init()function - Automatically instantiated on server start
Using api.AddControllers (Internal)
This is used internally by the framework:
func init() {
api.AddControllers(func(ctx context.Context, svc *service.Service) api.Controller {
return NewBuiltInController(svc)
})
}
When to use:
- Only when extending the framework itself
- Not recommended for application development
- Provides access to internal service implementation
Resource IDs
EZ-Console uses UUID-based ResourceID for all public APIs.
Base Model
Every model embeds the Base struct:
type Base struct {
ID uint `gorm:"primarykey" json:"-"`
ResourceID string `gorm:"uniqueIndex;size:36" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
Field Explanation:
ID: Internal auto-incrementing primary key (hidden from JSON)ResourceID: Public UUID identifier (exposed asidin JSON)CreatedAt: Timestamp when record was createdUpdatedAt: Timestamp when record was last updatedDeletedAt: Soft delete timestamp (null if not deleted)
Why Two IDs?
Internal ID (ID):
- Used for database joins and relationships
- Auto-incrementing for performance
- Never exposed in APIs
- Used internally only
Resource ID (ResourceID):
- Used in all external APIs
- UUID format prevents enumeration
- Can be used across distributed systems
- Safe to expose publicly
Usage Example
// Define model
type Product struct {
Base
Name string `json:"name"`
Price float64 `json:"price"`
CategoryID uint `json:"-"` // Internal FK
Category Category `gorm:"foreignKey:CategoryID" json:"category"`
}
// Query by ResourceID (external)
var product Product
db.Where("resource_id = ?", resourceID).First(&product)
// Join using internal ID (performance)
db.Joins("Category").
Where("products.id = ?", product.ID).
Find(&products)
JSON Serialization
The ResourceID field is automatically serialized as id:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Product Name",
"created_at": "2024-01-01T12:00:00Z"
}
Notice that:
- ✅
ResourceIDappears asid - ✅
CreatedAtandUpdatedAtare included - ❌
IDis hidden (json:"-") - ❌
DeletedAtis hidden
Soft Deletes
All models support soft deletes through GORM's DeletedAt field.
How Soft Delete Works
// Soft delete - sets DeletedAt to current time
db.Delete(&product)
// Record is not physically deleted
// DeletedAt: 2024-01-01 12:00:00
// Queries automatically exclude soft-deleted records
db.Find(&products) // Won't include deleted products
Working with Soft Deletes
// Normal delete (soft)
db.Delete(&user)
// Include soft-deleted in query
db.Unscoped().Find(&users)
// Find soft-deleted records only
db.Where("deleted_at IS NOT NULL").Unscoped().Find(&users)
// Restore soft-deleted record
db.Model(&user).Update("deleted_at", nil)
// Permanent delete
db.Unscoped().Delete(&user)
Benefits
- Data Recovery: Can restore accidentally deleted data
- Audit Trail: Maintain complete history
- Compliance: Meet data retention requirements
- References: Maintain referential integrity
Considerations
- Database size grows over time
- May need periodic cleanup
- Unique constraints need special handling
- Queries need
Unscoped()to include deleted records
Authentication & Authorization Flow
Understanding the auth flow is crucial for building secure applications.
Authentication Flow
1. User submits username/password
↓
2. Server validates credentials
↓
3. Server generates JWT token
↓
4. Client stores token (localStorage/cookie)
↓
5. Client sends token in subsequent requests
(Authorization: Bearer <token>)
↓
6. Middleware validates token
↓
7. User info extracted and stored in context
↓
8. Request proceeds to controller
JWT Token Structure
{
"user_id": "user-uuid",
"username": "john.doe",
"email": "[email protected]",
"roles": ["admin", "user"],
"exp": 1704153600,
"iat": 1704067200
}
Protected Routes
// Require authentication only
router.GET("/profile",
middleware.RequireAuth(),
controller.GetProfile,
)
// Require specific permission
router.POST("/users",
middleware.RequireAuth(),
middleware.RequirePermission("users:create"),
controller.CreateUser,
)
// Multiple permissions (OR logic)
router.DELETE("/users/:id",
middleware.RequireAuth(),
middleware.RequireAnyPermission("users:delete", "admin:all"),
controller.DeleteUser,
)
RBAC Structure
User
├─ Role 1
│ ├─ Permission Group 1
│ │ ├─ Permission 1 (users:read)
│ │ └─ Permission 2 (users:write)
│ └─ Permission Group 2
│ └─ Permission 3 (reports:read)
└─ Role 2
└─ Permission Group 3
└─ Permission 4 (admin:all)
Permission Naming Convention
Use the format: resource:action
Examples:
users:read- Read user datausers:write- Create/update usersusers:delete- Delete usersroles:manage- Manage rolesadmin:all- All administrative actions
Audit Logging
EZ-Console automatically tracks user actions for compliance and debugging.
Using StartAudit
func (c *UserController) CreateUser(ctx *gin.Context) {
var req CreateUserRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
util.RespondWithError(ctx, util.NewErrorMessage("E4001", "Invalid request", err))
return
}
var user *model.User
err := c.svc.StartAudit(ctx, "", func(auditLog *model.AuditLog) error {
// Perform the actual operation
user, err = c.svc.User().Create(ctx.Request.Context(), req)
// Configure audit log
auditLog.ResourceType = "user"
auditLog.ResourceID = user.ResourceID
auditLog.Action = "create"
auditLog.Details = map[string]interface{}{
"username": user.Username,
"email": user.Email,
}
return err
})
if err != nil {
util.RespondWithError(ctx, util.NewErrorMessage("E5001", "Failed to create user", err))
return
}
util.RespondWithSuccess(ctx, http.StatusCreated, user)
}
Automatically Captured Data
The framework automatically captures:
- User Info: User ID, username
- Request Info: IP address, user agent
- Geolocation: Country, city (via GeoIP)
- Timestamp: When action occurred
- Result: Success or failure
What You Provide
You specify:
- Resource Type:
user,product,order, etc. - Resource ID: The ID of the affected resource
- Action:
create,update,delete,login, etc. - Details: Additional context (optional)
Example Audit Log
{
"id": "audit-uuid",
"user_id": "user-uuid",
"username": "john.doe",
"resource_type": "user",
"resource_id": "new-user-uuid",
"action": "create",
"ip_address": "203.0.113.42",
"country": "United States",
"city": "New York",
"user_agent": "Mozilla/5.0...",
"details": {
"username": "jane.doe",
"email": "[email protected]"
},
"created_at": "2024-01-01T12:00:00Z"
}
Middleware Pipeline
Middleware functions process requests before they reach controllers.
Middleware Order
The order matters! Here's the typical sequence:
Request
↓
1. Recovery (panic handling)
↓
2. Logging (start)
↓
3. CORS
↓
4. Authentication
↓
5. Permission Check
↓
6. Rate Limiting
↓
7. Controller
↓
8. Response
↓
9. Metrics Collection
↓
10. Logging (end)
↓
Response
Built-in Middleware
Recovery:
router.Use(middleware.Recovery())
Catches panics and returns 500 error.
Logging:
router.Use(middleware.Logging())
Logs all requests and responses with trace ID.
CORS:
router.Use(middleware.CORS())
Handles cross-origin requests.
Authentication:
router.Use(middleware.RequireAuth())
Validates JWT tokens.
Permission:
router.Use(middleware.RequirePermission("resource:action"))
Checks user permissions.
Custom Middleware
func RateLimiter() gin.HandlerFunc {
limiter := rate.NewLimiter(100, 200) // 100 req/sec, burst 200
return func(ctx *gin.Context) {
if !limiter.Allow() {
util.RespondWithError(ctx, util.NewErrorMessage("E4291", "Rate limit exceeded"))
ctx.Abort()
return
}
ctx.Next()
}
}
// Use it
router.Use(RateLimiter())
Next Steps
Now that you understand the core concepts:
- Backend Development - Learn to build controllers and services
- Database & Models - Understand GORM and data models
- Authentication - Deep dive into auth system
- Frontend Development - Start building React interfaces
Key Takeaways
✅ Controllers handle HTTP, Services handle logic
✅ Use standard response formats for consistency
✅ Use ResourceID for external APIs, ID internally
✅ Soft deletes preserve data and history
✅ Authentication via JWT, Authorization via RBAC
✅ Audit logging for compliance and debugging
✅ Middleware processes requests in a pipeline
Questions about core concepts? Ask in GitHub Discussions.