Skip to content

Hooks & Customization

GrydCrud provides lifecycle hooks to customize operation behavior without modifying core logic.

Hook Overview

Each operation provides specific hooks:

OperationHooks
CreateBeforeValidationAsync, BeforeSaveAsync, AfterSaveAsync
UpdateFindEntityAsync, BeforeUpdateAsync, AfterUpdateAsync
DeleteBeforeDeleteAsync, AfterDeleteAsync
GetByIdFindEntityAsync
QueryBuildFilterExpression, ApplySorting

Create Operation Hooks

BeforeValidationAsync

Called before DTO validation. Use for pre-processing or early validation.

csharp
public class ProductCreateOperation : DefaultCreateOperation<Product, CreateProductDto, ProductDto>
{
    private readonly ISlugService _slugService;
    private readonly ICategoryRepository _categoryRepository;
    
    protected override async Task<Result> BeforeValidationAsync(
        CreateProductDto dto,
        CancellationToken cancellationToken)
    {
        // Check if category exists
        var categoryExists = await _categoryRepository.ExistsAsync(dto.CategoryId, cancellationToken);
        if (!categoryExists)
        {
            return Result.Failure("CATEGORY_NOT_FOUND", "The specified category does not exist");
        }
        
        // Check for duplicate slug
        var slug = _slugService.Generate(dto.Name);
        var slugExists = await _repository.AnyAsync(p => p.Slug == slug, cancellationToken);
        if (slugExists)
        {
            return Result.Failure("DUPLICATE_SLUG", "A product with this name already exists");
        }
        
        return Result.Success();
    }
}

BeforeSaveAsync

Called after mapping, before saving. Use for setting computed values.

csharp
protected override async Task<Result> BeforeSaveAsync(
    Product entity,
    CreateProductDto dto,
    CancellationToken cancellationToken)
{
    // Generate unique SKU
    entity.Sku = await GenerateSkuAsync(entity.CategoryId, cancellationToken);
    
    // Set computed values
    entity.Slug = _slugService.Generate(dto.Name);
    entity.SearchKeywords = GenerateSearchKeywords(dto.Name, dto.Description);
    
    // Set audit fields
    entity.CreatedBy = _currentUser.UserId;
    entity.CreatedAt = DateTime.UtcNow;
    
    return Result.Success();
}

AfterSaveAsync

Called after successful save. Use for side effects.

csharp
protected override async Task AfterSaveAsync(
    Product entity,
    CreateProductDto dto,
    CancellationToken cancellationToken)
{
    // Publish domain event
    await _eventBus.PublishAsync(new ProductCreatedEvent
    {
        ProductId = entity.Id,
        CategoryId = entity.CategoryId,
        CreatedBy = _currentUser.UserId
    });
    
    // Update search index
    await _searchIndexer.IndexProductAsync(entity);
    
    // Send notification
    await _notificationService.NotifyNewProductAsync(entity);
    
    // Invalidate related caches
    await _cache.RemoveAsync($"category:{entity.CategoryId}:products");
}

Update Operation Hooks

FindEntityAsync

Customize how the entity is loaded for update.

csharp
public class ProductUpdateOperation : DefaultUpdateOperation<Product, UpdateProductDto, ProductDto>
{
    protected override async Task<Product?> FindEntityAsync(
        Guid id,
        CancellationToken cancellationToken)
    {
        // Custom loading - return null if not found
        // The base operation will handle the not found case
        return await Repository.GetByIdAsync(id, cancellationToken);
    }
}

BeforeUpdateAsync

Validate business rules before applying changes.

csharp
protected override async Task<Result> BeforeUpdateAsync(
    Product entity,
    UpdateProductDto dto,
    CancellationToken cancellationToken)
{
    // Authorization check
    if (!_currentUser.HasPermission("update:products") && 
        entity.CreatedBy != _currentUser.UserId)
    {
        return Result.Failure("FORBIDDEN", "You don't have permission to edit this product");
    }
    
    // Business rule: can't reduce price below cost
    if (dto.Price < entity.CostPrice)
    {
        return Result.Failure("INVALID_PRICE", "Price cannot be below cost price");
    }
    
    // Business rule: can't deactivate product with pending orders
    if (!dto.IsActive && entity.IsActive)
    {
        var hasPendingOrders = await _orderRepository
            .AnyAsync(o => o.ProductId == entity.Id && o.Status == OrderStatus.Pending, cancellationToken);
        
        if (hasPendingOrders)
        {
            return Result.Failure("HAS_PENDING_ORDERS", "Cannot deactivate product with pending orders");
        }
    }
    
    return Result.Success();
}

AfterUpdateAsync

Handle post-update side effects.

csharp
protected override async Task AfterUpdateAsync(
    Product entity,
    UpdateProductDto dto,
    CancellationToken cancellationToken)
{
    // Track changes for audit
    await _auditLogger.LogAsync(new AuditEvent
    {
        EntityType = "Product",
        EntityId = entity.Id.ToString(),
        Action = "Update",
        ChangedBy = _currentUser.UserId,
        Changes = _changeTracker.GetChanges(entity)
    });
    
    // Update search index
    await _searchIndexer.UpdateProductAsync(entity);
    
    // Invalidate caches
    await _cache.RemoveAsync($"product:{entity.Id}");
    await _cache.RemoveAsync($"category:{entity.CategoryId}:products");
    
    // Notify price change subscribers
    if (dto.Price != entity.OriginalPrice)
    {
        await _eventBus.PublishAsync(new ProductPriceChangedEvent
        {
            ProductId = entity.Id,
            OldPrice = entity.OriginalPrice,
            NewPrice = dto.Price
        });
    }
}

Delete Operation Hooks

BeforeDeleteAsync

Validate deletion rules.

csharp
public class ProductDeleteOperation : DefaultDeleteOperation<Product>
{
    protected override async Task<Result> BeforeDeleteAsync(
        Product entity,
        CancellationToken cancellationToken)
    {
        // Check for active orders
        var hasActiveOrders = await _orderRepository
            .AnyAsync(o => o.ProductId == entity.Id && 
                          o.Status != OrderStatus.Completed && 
                          o.Status != OrderStatus.Cancelled,
                     cancellationToken);
        
        if (hasActiveOrders)
        {
            return Result.Failure("HAS_ACTIVE_ORDERS", 
                "Cannot delete product with active orders. Cancel or complete orders first.");
        }
        
        // Check for inventory
        if (entity.Stock > 0)
        {
            return Result.Failure("HAS_STOCK", 
                "Cannot delete product with stock. Transfer or write off inventory first.");
        }
        
        return Result.Success();
    }
}

AfterDeleteAsync

Clean up related resources.

csharp
protected override async Task AfterDeleteAsync(
    Product entity,
    CancellationToken cancellationToken)
{
    // Delete product images from storage
    await _blobStorage.DeleteContainerAsync($"products/{entity.Id}");
    
    // Remove from search index
    await _searchIndexer.RemoveProductAsync(entity.Id);
    
    // Invalidate caches
    await _cache.RemoveAsync($"product:{entity.Id}");
    await _cache.RemoveAsync($"category:{entity.CategoryId}:products");
    await _cache.RemoveAsync("products:count");
    
    // Publish event
    await _eventBus.PublishAsync(new ProductDeletedEvent
    {
        ProductId = entity.Id,
        DeletedBy = _currentUser.UserId,
        DeletedAt = DateTime.UtcNow
    });
}

Query Operation Hooks

BuildFilterExpression

Define filtering logic.

csharp
public class ProductQueryOperation : DefaultQueryOperation<Product, ProductDto, ProductQueryParameters>
{
    protected override Expression<Func<Product, bool>> BuildFilterExpression(
        ProductQueryParameters parameters)
    {
        // Build predicate dynamically
        var predicate = PredicateBuilder.True<Product>();
        
        // Soft delete filter
        if (!parameters.IncludeDeleted)
        {
            predicate = predicate.And(p => !p.IsDeleted);
        }
        
        // Category filter
        if (parameters.CategoryId.HasValue)
        {
            predicate = predicate.And(p => p.CategoryId == parameters.CategoryId);
        }
        
        // Price range
        if (parameters.MinPrice.HasValue)
        {
            predicate = predicate.And(p => p.Price >= parameters.MinPrice);
        }
        if (parameters.MaxPrice.HasValue)
        {
            predicate = predicate.And(p => p.Price <= parameters.MaxPrice);
        }
        
        // Active filter
        if (parameters.IsActive.HasValue)
        {
            predicate = predicate.And(p => p.IsActive == parameters.IsActive);
        }
        
        // Search
        if (!string.IsNullOrWhiteSpace(parameters.Search))
        {
            var search = parameters.Search.ToLower();
            predicate = predicate.And(p => 
                p.Name.ToLower().Contains(search) ||
                p.Description.ToLower().Contains(search) ||
                p.Sku.ToLower().Contains(search));
        }
        
        // Tags
        if (parameters.Tags?.Any() == true)
        {
            predicate = predicate.And(p => 
                p.Tags.Any(t => parameters.Tags.Contains(t)));
        }
        
        return predicate;
    }
}

ApplySorting

Custom sorting logic. Note: This works on IEnumerable<TEntity> for in-memory sorting.

csharp
protected override IEnumerable<Product> ApplySorting(
    IEnumerable<Product> entities,
    ProductQueryParameters parameters)
{
    var isDescending = parameters.SortDirection == SortDirection.Descending;
    
    return parameters.SortBy?.ToLower() switch
    {
        "price" => isDescending 
            ? entities.OrderByDescending(p => p.Price) 
            : entities.OrderBy(p => p.Price),
        
        "name" => isDescending 
            ? entities.OrderByDescending(p => p.Name) 
            : entities.OrderBy(p => p.Name),
        
        "rating" => entities.OrderByDescending(p => p.AverageRating),
        
        "popularity" => entities.OrderByDescending(p => p.ViewCount),
        
        "newest" => entities.OrderByDescending(p => p.CreatedAt),
        
        "bestselling" => entities.OrderByDescending(p => p.TotalSales),
        
        _ => entities.OrderByDescending(p => p.CreatedAt) // Default: newest first
    };
}

Creating Custom Operations

Step 1: Inherit from Default Operation

csharp
public class AdvancedProductCreateOperation : DefaultCreateOperation<Product, CreateProductDto, ProductDto>
{
    private readonly IInventoryService _inventoryService;
    private readonly IPricingService _pricingService;
    private readonly IImageProcessor _imageProcessor;
    
    public AdvancedProductCreateOperation(
        IRepository<Product> repository,
        IUnitOfWork unitOfWork,
        IEntityMapper mapper,
        IInventoryService inventoryService,
        IPricingService pricingService,
        IImageProcessor imageProcessor)
        : base(repository, unitOfWork, mapper)
    {
        _inventoryService = inventoryService;
        _pricingService = pricingService;
        _imageProcessor = imageProcessor;
    }
    
    // Override hooks as needed...
}

Step 2: Register Custom Operation

csharp
// Program.cs
builder.Services.AddCrudFor<Product, ProductDto, CreateProductDto, UpdateProductDto>()
    .UseCreateOperation<AdvancedProductCreateOperation>()
    .UseUpdateOperation<AdvancedProductUpdateOperation>()
    .UseDeleteOperation<AdvancedProductDeleteOperation>();

Composing Multiple Behaviors

Use decorator pattern for cross-cutting concerns:

csharp
// Logging decorator
public class LoggingCreateOperationDecorator<TEntity, TCreateDto, TResultDto> 
    : ICreateOperation<TEntity, TCreateDto, TResultDto>
    where TEntity : class
{
    private readonly ICreateOperation<TEntity, TCreateDto, TResultDto> _inner;
    private readonly ILogger _logger;
    
    public async Task<Result<TResultDto>> ExecuteAsync(
        TCreateDto dto,
        CancellationToken cancellationToken = default)
    {
        _logger.LogInformation("Creating {Entity} with {@Dto}", typeof(TEntity).Name, dto);
        
        var stopwatch = Stopwatch.StartNew();
        var result = await _inner.ExecuteAsync(dto, cancellationToken);
        stopwatch.Stop();
        
        if (result.IsSuccess)
        {
            _logger.LogInformation("Created {Entity} in {Elapsed}ms", 
                typeof(TEntity).Name, stopwatch.ElapsedMilliseconds);
        }
        else
        {
            _logger.LogWarning("Failed to create {Entity}: {Error}", 
                typeof(TEntity).Name, result.Error);
        }
        
        return result;
    }
}

// Registration
builder.Services.Decorate<ICreateOperation<Product, CreateProductDto, ProductDto>, 
    LoggingCreateOperationDecorator<Product, CreateProductDto, ProductDto>>();

Best Practices

  1. Keep hooks focused - Each hook should do one thing
  2. Return early on failure - Don't continue processing if validation fails
  3. Use async/await properly - All hooks are async for good reason
  4. Don't throw exceptions - Use Result pattern for errors
  5. Consider performance - Avoid expensive operations in hot paths
  6. Test hooks independently - Each hook should be unit testable

Released under the MIT License.