Backend Customization
Learn how to customize and extend Spiderly's generated backend code using service inheritance, partial classes, and interface replacement.
Overview
Spiderly is built on the Template Method Pattern, providing multiple extension mechanisms:
- Service Inheritance - Extend generated base classes and override virtual methods
- Partial Classes - Add custom members to generated partial classes (DTOs, Mapper, PermissionCodes)
- Interface Replacement - Implement Spiderly interfaces and register your own services in DI for complete behavior replacement
Services follow this inheritance pattern:
Spiderly Framework Base Classes (e.g., ServiceBase)
↓
Generated Classes - readonly (e.g., ProductServiceGenerated)
↓
Your Classes (e.g., ProductService) - override virtual methods hereEach entity gets its own generated service class. When you run spiderly init, the CLI creates skeleton files for your customizations:
Backend\YourAppName.Business\
├── Services\
│ ├── EntityServices\ ← Your entity service overrides (e.g., ProductService.cs)
│ ├── AuthorizationService.cs
│ └── SecurityService.cs
│
├── DataMappers\
│ └── Mapper.cs ← Partial class
│
└── Enums\
└── PermissionCodes.cs ← Partial class
Backend\YourAppName.WebAPI\Controllers\
├── SecurityController.cs
└── UserController.csEntity Services
Override virtual methods in your entity service class to customize entity operations. Create an {Entity}Service class that inherits from {Entity}ServiceGenerated and mark it with [SpiderlyService] so the source generator picks it up for DI registration.
Entity Lifecycle Hooks
OnBefore{Entity}IsMapped()- Customize before DTO-to-entity conversionOnBefore{Entity}Insert()- Execute logic before inserting a new entityOnBefore{Entity}Update()- Execute logic before updating an existing entityOnBefore{Entity}Delete()- Execute logic before deleting an entityOnBefore{Entity}ListDelete()- Execute logic before bulk deletion
Save Operation Hooks
OnBeforeSave{Entity}AndReturnMainUIFormDTO()- Validate or modify before saveOnAfterSave{Entity}AndReturnMainUIFormDTO()- Execute post-save business logic
Save Flow Execution Order
The generated save method runs these steps in order:
1. SaveBody validation (SaveBodyDTOValidationRules — usually empty)
2. OnBeforeSave{Entity}AndReturnMainUIFormDTO(SaveBodyDTO)
3. DTO validation ({Entity}DTOValidationRules — NotEmpty, Length, etc.)
4. OnBefore{Entity}IsMapped(DTO)
5. OnBefore{Entity}Insert(entity, DTO) — or — OnBefore{Entity}Update(entity, DTO)
6. SaveChangesAsync
7. Update M2M + ordered O2M collections
8. OnAfterSave{Entity}AndReturnMainUIFormDTO(SaveBodyDTO, MainUIFormDTO)Step 2 runs before step 3 — this means OnBeforeSave can set server-generated fields (e.g., [UIDoNotGenerate] + [Required] properties like hashes or computed values) before DTO validation runs. Step 8 runs after everything is persisted (including M2M updates), making it the right place for side effects like sending notifications, indexing, or cache invalidation.
Get Hooks
OnAfterGet{Entity}MainUIFormDTO()- Enrich the DTO with computed fields after it's constructed
protected override async Task OnAfterGetProductMainUIFormDTO(
ProductMainUIFormDTO mainUIFormDTO)
{
mainUIFormDTO.ProductDTO.ComputedField = await CalculateValue(mainUIFormDTO.ProductDTO.Id);
}Blob/File Processing Hooks
OnBefore{Property}BlobFor{Entity}UploadIsAuthorized()- Custom authorization for file uploadsOnBefore{Property}BlobFor{Entity}IsUploaded()- Process files before storageValidateImageFor{Property}Of{Entity}()- Custom image dimension validationOptimizeImageFor{Property}Of{Entity}()- Custom image optimization
Query Hooks
GetAll{Property}QueryFor{Entity}()- Customize lazy-loaded relationship queries
Example: Overriding Entity Service Hooks
// File: Backend\YourAppName.Business\Services\EntityServices\ProductService.cs
using Spiderly.Shared.Attributes;
namespace YourAppName.Business.Services
{
[SpiderlyService]
public class ProductService : ProductServiceGenerated
{
private readonly AuthenticationService _authenticationService;
private readonly EmailingService _emailingService;
public ProductService(
EntityServiceDependencies deps,
AuthenticationService authenticationService,
EmailingService emailingService
) : base(deps)
{
_authenticationService = authenticationService;
_emailingService = emailingService;
}
protected override async Task OnBeforeProductInsert(Product product, ProductDTO productDTO)
{
// Custom logic before inserting a product
product.CreatedByUserId = _authenticationService.GetCurrentUserId();
}
protected override async Task OnAfterSaveProductAndReturnMainUIFormDTO(ProductSaveBodyDTO saveBodyDTO, ProductMainUIFormDTO mainUIFormDTO)
{
// Send notification after product is saved
await _emailingService.SendProductCreatedNotification(mainUIFormDTO.ProductDTO.Id);
}
}
}The EntityServiceDependencies object (_deps) provides access to shared framework services: Context, ExcelService, AuthorizationService, FileManager, Localizer, and ServiceProvider. Add custom dependencies to your entity service constructor.
DI Registration
All entity service DI registration is auto-generated — you never register entity services manually. Call services.AddEntityServices() in your startup configuration. The source generator auto-detects user-written {Entity}Service classes marked with [SpiderlyService] and registers them alongside the generated base classes:
- If you create
[SpiderlyService] ProductService : ProductServiceGenerated, the generator registers both the concrete type and a forwarding registration so that resolvingProductServiceGeneratedreturns yourProductService. - If no user override exists for an entity, only the generated service is registered.
- Without the
[SpiderlyService]marker, the override is invisible to the generator and the forwarding registration is not emitted — the generated base wins at runtime.
Security Service
Your SecurityService class inherits from SecurityServiceBase<TUser> from the Spiderly.Security package.
Available Hooks
CreateLoginEmailTemplate(string verificationCode)- Customize the login verification emailOnAfterLogin(AuthResultDTO authResultDTO)- Execute logic after successful login
Example: Overriding Security Service Hooks
// File: Backend\YourAppName.Business\Services\SecurityService.cs
using Spiderly.Security.Services;
namespace YourAppName.Business.Services
{
public class SecurityService<TUser> : SecurityServiceBase<TUser> where TUser : class, IUser, new()
{
// ... constructor and fields ...
public override EmailVerifyUIDTO CreateLoginEmailTemplate(string verificationCode)
{
return new EmailVerifyUIDTO
{
Subject = "Your Login Code for MyApp",
Body = $"Your verification code is: <strong>{verificationCode}</strong>"
};
}
public override async Task OnAfterLogin(AuthResultDTO authResultDTO)
{
// Log successful login
_logger.LogInformation($"User {authResultDTO.UserId} logged in");
await base.OnAfterLogin(authResultDTO);
}
}
}Authorization Service
Your AuthorizationService class inherits from AuthorizationServiceGenerated. Mark it with [SpiderlyService] and override virtual methods to customize permission checks.
Example: Custom Authorization Logic
// File: Backend\YourAppName.Business\Services\AuthorizationService.cs
using Spiderly.Shared.Attributes;
namespace YourAppName.Business.Services
{
[SpiderlyService]
public class AuthorizationService : AuthorizationServiceGenerated
{
// ... constructor and fields ...
public override async Task AuthorizeProductDeleteAndThrow(long productId)
{
await _context.WithTransactionAsync(async () =>
{
// Custom logic: Only allow deletion of products created by the current user
var product = await _context.DbSet<Product>().FindAsync(productId);
if (product.CreatedByUserId != _authenticationService.GetCurrentUserId())
{
throw new UnauthorizedException("You can only delete products you created.");
}
await base.AuthorizeProductDeleteAndThrow(productId);
});
}
}
}Partial Classes
Some generated classes use the partial class pattern, allowing you to add custom members in a separate file.
Custom Mappings (Mapper)
The Mapper class is generated as a partial class. You can add your own custom mapping methods here. If you define a method with the same name as a generated one (e.g., {Entity}DTOToEntityConfig, {Entity}ToDTOConfig, {Entity}ProjectToConfig), the generator will skip that method and use yours instead.
// File: Backend\YourAppName.Business\DataMappers\Mapper.cs
using Mapster;
using Spiderly.Shared.Attributes;
using YourAppName.Business.DTO;
using YourAppName.Business.Entities;
namespace YourAppName.Business.DataMappers
{
[SpiderlyDataMapper]
public static partial class Mapper
{
// Custom mapping method for your own use
public static ProductSummaryDTO ToSummaryDTO(this Product product)
{
return new ProductSummaryDTO
{
Id = product.Id,
Name = product.Name,
Price = product.Price
};
}
// Override the generated mapping configuration for Product -> ProductDTO
// This method won't be generated because you defined it here
public static TypeAdapterConfig ProductToDTOConfig()
{
TypeAdapterConfig config = new();
config
.NewConfig<Product, ProductDTO>()
.Map(dest => dest.FullName, src => $"{src.Name} - {src.Category}")
;
return config;
}
}
}Custom Permission Codes
The PermissionCodes class is generated as a partial class. Add custom permission codes in your PermissionCodes.cs:
// File: Backend\YourAppName.Business\Enums\PermissionCodes.cs
namespace YourAppName.Business.Enums
{
public static partial class PermissionCodes
{
// Add custom permission codes here
public static string ExportReports { get; } = "ExportReports";
public static string ManageSettings { get; } = "ManageSettings";
}
}Custom DTO Properties
DTOs are generated as partial classes. Create a matching partial class to add custom properties or methods:
// File: Backend\YourAppName.Business\DTO\ProductDTO.cs
namespace YourAppName.Business.DTO
{
public partial class ProductDTO
{
// Add custom computed properties
public decimal PriceWithTax => Price * 1.2m;
// Add custom methods
public string GetDisplayName() => $"{Name} (${Price})";
}
}Controller Overrides
Generated controllers create a base class (e.g., ProductBaseController) with virtual methods. Create your own controller that inherits from the base and overrides specific endpoints or add new ones.
Example: Overriding a Controller Method
// File: Backend\YourAppName.WebAPI\Controllers\ProductController.cs
using YourAppName.Business.Services;
using Spiderly.Shared.Attributes;
using Spiderly.Shared.Interfaces;
using Microsoft.Extensions.Localization;
namespace YourAppName.WebAPI.Controllers
{
[SpiderlyController]
[ApiController]
[Route("/api/Product/[action]")]
public class ProductController : ProductBaseController
{
public ProductController(
IApplicationDbContext context,
IServiceProvider serviceProvider,
IStringLocalizer localizer
) : base(context, serviceProvider, localizer)
{
}
public override async Task<PaginatedResultDTO<ProductDTO>> GetPaginatedProductList(FilterDTO filterDTO)
{
// Add custom filtering logic
filterDTO.AdditionalFilters.Add("IsActive", "true");
return await base.GetPaginatedProductList(filterDTO);
}
}
}Replacing Services via Interfaces
For complete behavior replacement, Spiderly provides interfaces that you can implement and register in the DI container. This is useful when you need to completely change how a service works rather than just extending it.
Available Interfaces (Spiderly.Security)
| Interface | Description |
|---|---|
ITokenStorage<T> | Token storage operations (in-memory, Redis, or custom) |
IJwtAuthManager | JWT token generation, validation, and refresh |
Example: Custom Token Storage
// Implement your own token storage
public class DatabaseTokenStorage<T> : ITokenStorage<T> where T : class, IExpirableToken
{
private readonly IApplicationDbContext _context;
public DatabaseTokenStorage(IApplicationDbContext context)
{
_context = context;
}
public async Task AddOrUpdateAsync(string key, T token)
{
// Store token in database
}
public async Task<T> TryGetValueAsync(string key)
{
// Retrieve token from database
}
public async Task<bool> TryRemoveAsync(string key)
{
// Remove token from database
}
public async Task<IEnumerable<KeyValuePair<string, T>>> GetByIndexAsync(string indexName, string indexValue)
{
// Query tokens by a secondary index (e.g., "UserId" or "Email")
}
// ... implement other methods
}
// Register in Program.cs
builder.Services.AddScoped(typeof(ITokenStorage<>), typeof(DatabaseTokenStorage<>));Secondary Index Constants
When the security service calls GetByIndexAsync, it uses these predefined index names:
| Constant | Value | Purpose |
|---|---|---|
RefreshTokenDTO.UserIdIndex | "UserId" | Find all refresh tokens for a user (used during logout-all-sessions) |
LoginVerificationTokenDTO.EmailIndex | "Email" | Find pending login verifications by email |
Your custom ITokenStorage implementation must support querying by these indexes for the security service to work correctly.
Transaction Management
Every generated save/delete method wraps its logic in _context.WithTransactionAsync(...). All hooks called within that flow (e.g., OnBeforeInsert, OnAfterSave) automatically run inside the same transaction. Nested WithTransactionAsync calls reuse the existing transaction — you do not need to start your own transaction in hooks.
If you need a transaction in custom (non-hook) methods:
await _context.WithTransactionAsync(async () =>
{
// All DB operations here are transactional.
// If any operation throws, everything is rolled back.
});Exception Types
| Type | HTTP Status | When to Use |
|---|---|---|
BusinessException(message) | 400 | Validation the user can trigger through normal UI usage |
SecurityViolationException() | 403 | Impossible conditions, tampering, unauthorized access |
throw new BusinessException("Sale price must be less than regular price.");
throw new SecurityViolationException(); // Logs detailed message server-side, returns generic error to clientCommon Pitfalls
PostgreSQL MARS (Multiple Active Result Sets)
EF Core on PostgreSQL does not support multiple active result sets. You'll get a NpgsqlOperationInProgressException if you enumerate two queries concurrently on the same connection.
Fix 1 — Materialize with .Select() before starting another query:
// BAD — lazy enumeration holds the connection open
var dict = await _context.DbSet<Product>()
.ToDictionaryAsync(x => x.Id, x => x.Name);
// GOOD — materialize first
var dict = await _context.DbSet<Product>()
.Select(x => new { x.Id, x.Name })
.ToDictionaryAsync(x => x.Id, x => x.Name);Fix 2 — Use .Include() instead of lazy loading navigation properties:
// BAD — accessing Product.Category triggers lazy load while connection is busy
var products = await _context.DbSet<Product>().ToListAsync();
var names = products.Select(p => p.Category.Name); // MARS error
// GOOD — eager load
var products = await _context.DbSet<Product>()
.Include(p => p.Category)
.ToListAsync();