Layered Architecture in Detail
Bingo adopts a classic three-layer architecture design. This document explains the responsibility and design principles of each layer.
Three-Layer Architecture
┌─────────────────────────────────────────┐
│ Controller Layer │ HTTP/gRPC Handler Layer
│ - Parameter validation │
│ - Request/response conversion │
│ - Error handling │
└──────────────┬──────────────────────────┘
│ Depends on
▼
┌─────────────────────────────────────────┐
│ Business Layer (Biz) │ Business Logic Layer
│ - Business rules │
│ - Process orchestration │
│ - Transaction control │
└──────────────┬──────────────────────────┘
│ Depends on
▼
┌─────────────────────────────────────────┐
│ Store Layer │ Data Access Layer
│ - Database operations │
│ - Cache operations │
│ - Third-party service calls │
└─────────────────────────────────────────┘Controller Layer (HTTP/gRPC Handler)
Responsibilities
- Receive Requests: Handle HTTP/gRPC requests
- Parameter Validation: Bind and validate request parameters
- Call Business Logic: Invoke Biz layer to process business logic
- Return Responses: Construct and return responses
Code Example
go
// internal/apiserver/controller/v1/user/user.go
type UserController struct {
biz biz.IBiz
}
func (ctrl *UserController) Get(c *gin.Context) {
// 1. Parameter validation
var req GetUserRequest
if err := c.ShouldBindUri(&req); err != nil {
core.WriteResponse(c, errno.ErrBind, nil)
return
}
// 2. Call business layer
user, err := ctrl.biz.Users().Get(c.Context(), req.UserID)
if err != nil {
core.WriteResponse(c, err, nil)
return
}
// 3. Return response
core.WriteResponse(c, nil, user)
}Design Principles
- Thin Controllers: Only handle parameter processing and responses, no business logic
- Unified Responses: Use consistent response format
- Error Handling: Unified error handling mechanism
- Version Isolation: Different API versions in separate directories (
v1/,v2/)
What NOT to Do
❌ Don't write business logic in Controller
go
// Wrong example
func (ctrl *UserController) Create(c *gin.Context) {
// ❌ Business rules shouldn't be here
if user.Age < 18 {
return errors.New("Age too young")
}
// ❌ Password encryption shouldn't be here
hashedPassword := encrypt(user.Password)
}✅ Call Biz layer instead
go
// Correct example
func (ctrl *UserController) Create(c *gin.Context) {
var req CreateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
core.WriteResponse(c, errno.ErrBind, nil)
return
}
// ✅ Business logic goes to Biz layer
user, err := ctrl.biz.Users().Create(c.Context(), &req)
core.WriteResponse(c, err, user)
}Biz Layer (Business Logic)
Responsibilities
- Business Rules: Implement core business logic and rules
- Process Orchestration: Coordinate multiple Store operations
- Transaction Control: Handle database transactions
- Business Validation: Business-level validation
Code Example
go
// internal/apiserver/biz/user/user.go
type UserBiz struct {
ds store.IStore
}
func (b *UserBiz) Create(ctx context.Context, req *CreateUserRequest) (*model.User, error) {
// 1. Business rule validation
if err := b.validateUser(req); err != nil {
return nil, err
}
// 2. Process business logic
req.Password = encryptPassword(req.Password)
// 3. Build model
user := &model.User{
Username: req.Username,
Password: req.Password,
Email: req.Email,
}
// 4. Persist data
if err := b.ds.Users().Create(ctx, user); err != nil {
return nil, err
}
// 5. Business process orchestration (e.g., send welcome email)
go b.sendWelcomeEmail(user.Email)
return user, nil
}
func (b *UserBiz) validateUser(req *CreateUserRequest) error {
// Business rule validation
if req.Age < 18 {
return errno.ErrUserAgeTooYoung
}
// Check if username already exists
exists, err := b.ds.Users().ExistsByUsername(ctx, req.Username)
if err != nil {
return err
}
if exists {
return errno.ErrUserAlreadyExists
}
return nil
}Design Principles
- Core Business: All business logic lives here
- Interface Programming: Depends on Store interface, not concrete implementation
- Testability: Unit test via Mock Store
- Transaction Control: Use Store's transaction methods when needed
Typical Scenarios
Scenario 1: Single Table Operation
go
func (b *UserBiz) Get(ctx context.Context, id uint64) (*model.User, error) {
return b.ds.Users().Get(ctx, id)
}Scenario 2: Multi-Table Operation Orchestration
go
func (b *OrderBiz) Create(ctx context.Context, req *CreateOrderRequest) error {
// 1. Check inventory
stock, err := b.ds.Products().GetStock(ctx, req.ProductID)
if err != nil {
return err
}
if stock < req.Quantity {
return errno.ErrInsufficientStock
}
// 2. Create order
order := &model.Order{...}
if err := b.ds.Orders().Create(ctx, order); err != nil {
return err
}
// 3. Decrease stock
if err := b.ds.Products().DecreaseStock(ctx, req.ProductID, req.Quantity); err != nil {
return err
}
return nil
}Scenario 3: Transaction Control
go
func (b *OrderBiz) Create(ctx context.Context, req *CreateOrderRequest) error {
// Use transaction
return b.ds.TX(ctx, func(ctx context.Context) error {
// Execute multiple operations in transaction
if err := b.ds.Orders().Create(ctx, order); err != nil {
return err
}
if err := b.ds.Products().DecreaseStock(ctx, productID, quantity); err != nil {
return err
}
return nil
})
}Store Layer (Data Access)
💡 For detailed design documentation, refer to Store Package Design
Responsibilities
- Database Operations: Encapsulate GORM operations
- Cache Operations: Redis cache read/write
- Data Transformation: Convert data formats
- Query Optimization: SQL optimization and index usage
Code Example
go
// internal/apiserver/store/user.go
type UserStore interface {
Create(ctx context.Context, user *model.User) error
Get(ctx context.Context, id uint64) (*model.User, error)
List(ctx context.Context, opts ListOptions) ([]*model.User, int64, error)
Update(ctx context.Context, user *model.User) error
Delete(ctx context.Context, id uint64) error
}
type userStore struct {
db *gorm.DB
}
func (s *userStore) Create(ctx context.Context, user *model.User) error {
return s.db.WithContext(ctx).Create(user).Error
}
func (s *userStore) Get(ctx context.Context, id uint64) (*model.User, error) {
var user model.User
if err := s.db.WithContext(ctx).First(&user, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errno.ErrUserNotFound
}
return nil, err
}
return &user, nil
}
func (s *userStore) List(ctx context.Context, opts ListOptions) ([]*model.User, int64, error) {
var users []*model.User
var count int64
db := s.db.WithContext(ctx).Model(&model.User{})
// Conditional query
if opts.Username != "" {
db = db.Where("username LIKE ?", "%"+opts.Username+"%")
}
// Count
if err := db.Count(&count).Error; err != nil {
return nil, 0, err
}
// Pagination
if err := db.Offset(opts.Offset).Limit(opts.Limit).Find(&users).Error; err != nil {
return nil, 0, err
}
return users, count, nil
}Design Principles
- Pure Data Operations: Only database/cache operations, no business logic
- Interface Definition: Each Store defines an interface
- Error Conversion: Convert database errors to business errors
- Query Optimization: Pay attention to N+1 problems, use Preload wisely
Cache Usage Example
go
func (s *userStore) Get(ctx context.Context, id uint64) (*model.User, error) {
// 1. Try to get from cache
cacheKey := fmt.Sprintf("user:%d", id)
var user model.User
if err := s.cache.Get(ctx, cacheKey, &user); err == nil {
return &user, nil
}
// 2. Cache miss, query from database
if err := s.db.WithContext(ctx).First(&user, id).Error; err != nil {
return nil, err
}
// 3. Write to cache
_ = s.cache.Set(ctx, cacheKey, &user, time.Hour)
return &user, nil
}Why Layered Architecture?
1. Separation of Concerns
Each layer focuses on its own responsibility:
- Controller focuses on HTTP protocol
- Biz focuses on business rules
- Store focuses on data access
2. Easy to Test
go
// Test Biz layer by mocking Store layer
func TestUserBiz_Create(t *testing.T) {
mockStore := &MockStore{}
biz := user.New(mockStore)
// Test business logic
err := biz.Create(ctx, req)
assert.NoError(t, err)
}3. Code Reuse
Biz layer can be reused by multiple Controllers:
HTTP Controller ──┐
├──→ User Biz ──→ User Store
gRPC Service ──┘4. Easy to Maintain
- Modify database operations: only change Store layer
- Modify business rules: only change Biz layer
- Modify API format: only change Controller layer
5. Team Collaboration
Different layers can be developed in parallel:
- Frontend developers: Mock Controller and develop in parallel
- Backend developers: Define interface first, develop in layers
Common Mistakes
Mistake 1: Cross-Layer Calls
❌ Controller directly calls Store
go
// Wrong
func (ctrl *UserController) Get(c *gin.Context) {
// ❌ Controller shouldn't directly call Store
user, err := ctrl.store.Users().Get(ctx, id)
}✅ Use Biz layer
go
// Correct
func (ctrl *UserController) Get(c *gin.Context) {
user, err := ctrl.biz.Users().Get(ctx, id)
}Mistake 2: Business Logic Leak
❌ Store layer contains business logic
go
// Wrong
func (s *userStore) Create(ctx context.Context, user *model.User) error {
// ❌ Business validation shouldn't be in Store layer
if user.Age < 18 {
return errors.New("Age too young")
}
return s.db.Create(user).Error
}✅ Business logic in Biz layer
go
// Correct: Biz layer validates
func (b *userBiz) Create(ctx context.Context, req *CreateUserRequest) error {
if req.Age < 18 {
return errno.ErrUserAgeTooYoung
}
return b.ds.Users().Create(ctx, user)
}
// Store layer only does data operations
func (s *userStore) Create(ctx context.Context, user *model.User) error {
return s.db.Create(user).Error
}Next Step
- Store Package Design - Deep dive into the generic data access layer design