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. 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:
- Backend Development - Learn to build controllers and services
- Database & Models - Understand GORM and data models
- Authentication & route security - Middleware (patterns) and auth system (JWT, OAuth, RBAC)
- 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.