Hooks & Customization
GrydCrud provides lifecycle hooks to customize operation behavior without modifying core logic.
Hook Overview
Each operation provides specific hooks:
| Operation | Hooks |
|---|---|
| Create | BeforeValidationAsync, BeforeSaveAsync, AfterSaveAsync |
| Update | FindEntityAsync, BeforeUpdateAsync, AfterUpdateAsync |
| Delete | BeforeDeleteAsync, AfterDeleteAsync |
| GetById | FindEntityAsync |
| Query | BuildFilterExpression, 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
- Keep hooks focused - Each hook should do one thing
- Return early on failure - Don't continue processing if validation fails
- Use async/await properly - All hooks are async for good reason
- Don't throw exceptions - Use Result pattern for errors
- Consider performance - Avoid expensive operations in hot paths
- Test hooks independently - Each hook should be unit testable