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. Route-level behavior (default login, WithoutAuthentication, RequirePermission) is documented in Middleware; JWT, OAuth, LDAP, MFA, and RBAC details are in Authentication & Authorization.

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
}

Route defaults (API)​

REST routes registered on the stock API group assume authentication is already enforced (WithAuthentication). Use middleware.WithoutAuthentication(router.Group(...)) for intentional public endpoints, and middleware.RequirePermission(...) (or RequireAnyPermission / RequireAllPermissions) when only some roles may call a handler. Examples: Middleware: default auth, public routes, permissions.

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​

Use resource:action (for example users:read, users:write, admin:all). Full tables and naming guidance: Authentication & Authorization.

Audit logging​

Wrap sensitive work in svc.StartAudit so EZ-Console records who did what, from which client, and whether it succeeded, while you set resource_type, resource_id, action, and optional details.

The full controller example, auto-captured fields, common action names, and sample JSON live in one place: Audit logging.

Middleware pipeline​

Requests pass through a middleware chain before they reach controllers. The stock binary adds engine-level middleware (recovery, telemetry, logging, CORS, and so on); the API route group then applies authentication by default and optional permission middleware on specific routes. See Middleware for ordering, WithoutAuthentication, and RequirePermission.

Per-route and group middleware attach to the router your controllers register. For global handlers on the stock binaryβ€”after the framework’s default Gin stack but before services and API routes are wiredβ€”use consoleserver.WithEngineOptions on NewCommandServer. See Middleware: global middleware and Command server options.

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 & route security - Middleware (patterns) and auth system (JWT, OAuth, RBAC)
  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 on the API group by default; authorization via RBAC (RequirePermission, etc.)
βœ… Audit logging for compliance and debugging
βœ… Middleware processes requests in a pipeline


Questions about core concepts? Ask in GitHub Discussions.