Business Logic with Services
Services contain your application's business logic. Learn how to create clean, testable, and maintainable service layers.
Service Layer Responsibility
Services should:
- ✅ Implement business logic and rules
- ✅ Perform database operations via GORM
- ✅ Manage transactions
- ✅ Call other services for orchestration
- ✅ Validate business constraints
- ✅ Return domain models
Services should NOT:
- ❌ Handle HTTP requests/responses
- ❌ Access
gin.Context - ❌ Format HTTP responses
- ❌ Determine HTTP status codes
Creating a Service
Basic Service Structure
package service
import (
"context"
"github.com/sven-victor/ez-console/pkg/model"
"gorm.io/gorm"
)
type ProductService struct {
db *gorm.DB
}
func NewProductService(db *gorm.DB) *ProductService {
return &ProductService{db: db}
}
CRUD Operations
// Create
func (s *ProductService) Create(ctx context.Context, req CreateProductRequest) (*model.Product, error) {
product := &model.Product{
Name: req.Name,
Description: req.Description,
Price: req.Price,
Stock: req.Stock,
}
if err := s.db.Create(product).Error; err != nil {
return nil, err
}
return product, nil
}
// Read
func (s *ProductService) GetByID(ctx context.Context, resourceID string) (*model.Product, error) {
var product model.Product
err := s.db.Where("resource_id = ?", resourceID).First(&product).Error
if err != nil {
return nil, err
}
return &product, nil
}
// Update
func (s *ProductService) Update(ctx context.Context, resourceID string, req UpdateProductRequest) (*model.Product, error) {
var product model.Product
// Find product
if err := s.db.Where("resource_id = ?", resourceID).First(&product).Error; err != nil {
return nil, err
}
// Update fields
if req.Name != nil {
product.Name = *req.Name
}
if req.Price != nil {
product.Price = *req.Price
}
// Save
if err := s.db.Save(&product).Error; err != nil {
return nil, err
}
return &product, nil
}
// Delete
func (s *ProductService) Delete(ctx context.Context, resourceID string) error {
return s.db.Where("resource_id = ?", resourceID).Delete(&model.Product{}).Error
}
// List with pagination
func (s *ProductService) List(ctx context.Context, search string, page, pageSize int) ([]*model.Product, int64, error) {
var products []*model.Product
var total int64
query := s.db.Model(&model.Product{})
// Search filter
if search != "" {
query = query.Where("name LIKE ?", "%"+search+"%")
}
// Count total
query.Count(&total)
// Paginate
offset := (page - 1) * pageSize
err := query.Offset(offset).Limit(pageSize).Find(&products).Error
return products, total, err
}
Transaction Management
Simple Transaction
func (s *OrderService) CreateOrder(ctx context.Context, req CreateOrderRequest) (*model.Order, error) {
var order *model.Order
err := s.db.Transaction(func(tx *gorm.DB) error {
// Create order
order = &model.Order{
UserID: req.UserID,
Total: req.Total,
}
if err := tx.Create(order).Error; err != nil {
return err
}
// Create order items
for _, item := range req.Items {
orderItem := &model.OrderItem{
OrderID: order.ID,
ProductID: item.ProductID,
Quantity: item.Quantity,
Price: item.Price,
}
if err := tx.Create(orderItem).Error; err != nil {
return err
}
// Update stock
if err := tx.Model(&model.Product{}).
Where("id = ?", item.ProductID).
Update("stock", gorm.Expr("stock - ?", item.Quantity)).Error; err != nil {
return err
}
}
return nil
})
return order, err
}
Calling Other Services
type OrderService struct {
db *gorm.DB
productService *ProductService
userService *UserService
}
func (s *OrderService) CreateOrder(ctx context.Context, req CreateOrderRequest) (*model.Order, error) {
// Validate user exists
user, err := s.userService.GetByID(ctx, req.UserID)
if err != nil {
return nil, fmt.Errorf("user not found: %w", err)
}
// Validate all products exist and have sufficient stock
for _, item := range req.Items {
product, err := s.productService.GetByID(ctx, item.ProductID)
if err != nil {
return nil, fmt.Errorf("product not found: %w", err)
}
if product.Stock < item.Quantity {
return nil, fmt.Errorf("insufficient stock for product %s", product.Name)
}
}
// Create order (see transaction example above)
// ...
return order, nil
}
Business Validation
func (s *ProductService) Create(ctx context.Context, req CreateProductRequest) (*model.Product, error) {
// Business validation
if req.Price <= 0 {
return nil, errors.New("price must be positive")
}
if req.Stock < 0 {
return nil, errors.New("stock cannot be negative")
}
// Check for duplicate name
var count int64
s.db.Model(&model.Product{}).Where("name = ?", req.Name).Count(&count)
if count > 0 {
return nil, errors.New("product name already exists")
}
// Create product
product := &model.Product{
Name: req.Name,
Description: req.Description,
Price: req.Price,
Stock: req.Stock,
}
if err := s.db.Create(product).Error; err != nil {
return nil, err
}
return product, nil
}
Error Handling
Define custom errors:
// service/errors.go
package service
import "errors"
var (
ErrProductNotFound = errors.New("product not found")
ErrInsufficientStock = errors.New("insufficient stock")
ErrInvalidPrice = errors.New("invalid price")
ErrDuplicateProductName = errors.New("product name already exists")
)
Use in service:
func (s *ProductService) GetByID(ctx context.Context, resourceID string) (*model.Product, error) {
var product model.Product
err := s.db.Where("resource_id = ?", resourceID).First(&product).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrProductNotFound
}
if err != nil {
return nil, fmt.Errorf("database error: %w", err)
}
return &product, nil
}
Logging
import "github.com/sven-victor/ez-utils/log"
func (s *ProductService) Create(ctx context.Context, req CreateProductRequest) (*model.Product, error) {
logger := log.GetContextLogger(ctx)
logger.Log("msg", "creating product", "name", req.Name)
product := &model.Product{
Name: req.Name,
Price: req.Price,
}
if err := s.db.Create(product).Error; err != nil {
logger.Log("msg", "failed to create product", "err", err)
return nil, err
}
logger.Log("msg", "product created", "id", product.ResourceID)
return product, nil
}
Testing Services
package service_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func setupTestDB(t *testing.T) *gorm.DB {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
assert.NoError(t, err)
// Migrate
db.AutoMigrate(&model.Product{})
return db
}
func TestProductService_Create(t *testing.T) {
db := setupTestDB(t)
service := NewProductService(db)
req := CreateProductRequest{
Name: "Test Product",
Price: 99.99,
Stock: 10,
}
product, err := service.Create(context.Background(), req)
assert.NoError(t, err)
assert.NotNil(t, product)
assert.Equal(t, "Test Product", product.Name)
assert.Equal(t, 99.99, product.Price)
}
Best Practices
DO ✅
- Keep services focused - single responsibility
- Use transactions - for multi-step operations
- Validate business rules - before database operations
- Return custom errors - for better error handling
- Use logging - track operations and errors
- Write tests - test business logic thoroughly
DON'T ❌
- Don't access HTTP layer - no gin.Context
- Don't format responses - return domain models
- Don't ignore errors - always handle errors
- Don't use global variables - use dependency injection
- Don't mix concerns - separate data access from business logic
Next Steps
- Learn about database models
- Implement error handling
- Add authentication logic
- Write Go tests for services (see examples above); for the React admin UI, see Frontend testing