Skip to main content

Core Concepts

Developer Intermediate

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.Context directly
  • ❌ Format HTTP responses
  • ❌ Handle HTTP status codes
  • ❌ Parse HTTP requests

Benefits of This Pattern

  1. Testability: Services can be tested without HTTP layer
  2. Reusability: Services can be called from multiple controllers
  3. Maintainability: Clear boundaries make code easier to understand
  4. 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 parameters
  • E4012 - Unauthorized (401) - Invalid auth token
  • E4031 - Forbidden (403) - Permission denied
  • E4041 - Not Found (404) - Resource not found

Server Errors (5xx):

  • E5001 - Internal Server Error (500) - General server error
  • E5002 - Database Error (500) - Database operation failed
  • E5003 - External Service Error (500) - External API failed

Controller Registration

EZ-Console provides two ways to register controllers.

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.Service interface
  • 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 as id in JSON)
  • CreatedAt: Timestamp when record was created
  • UpdatedAt: Timestamp when record was last updated
  • DeletedAt: 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:

  • ResourceID appears as id
  • CreatedAt and UpdatedAt are included
  • ID is hidden (json:"-")
  • DeletedAt is 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

  1. Data Recovery: Can restore accidentally deleted data
  2. Audit Trail: Maintain complete history
  3. Compliance: Meet data retention requirements
  4. 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 data
  • users:write - Create/update users
  • users:delete - Delete users
  • roles:manage - Manage roles
  • admin: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:

  1. Backend Development - Learn to build controllers and services
  2. Database & Models - Understand GORM and data models
  3. Authentication - Deep dive into auth system
  4. 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.