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​