Skip to main content

Business Logic with Services

Developer Intermediate

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 ✅

  1. Keep services focused - single responsibility
  2. Use transactions - for multi-step operations
  3. Validate business rules - before database operations
  4. Return custom errors - for better error handling
  5. Use logging - track operations and errors
  6. Write tests - test business logic thoroughly

DON'T ❌

  1. Don't access HTTP layer - no gin.Context
  2. Don't format responses - return domain models
  3. Don't ignore errors - always handle errors
  4. Don't use global variables - use dependency injection
  5. Don't mix concerns - separate data access from business logic

Next Steps