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:

  1. Service Inheritance - Extend generated base classes and override virtual methods
  2. Partial Classes - Add custom members to generated partial classes (DTOs, Mapper, PermissionCodes)
  3. 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 here

Each 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.cs

Entity 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 conversion
  • OnBefore{Entity}Insert() - Execute logic before inserting a new entity
  • OnBefore{Entity}Update() - Execute logic before updating an existing entity
  • OnBefore{Entity}ListDelete() - Execute logic before deletion (handles both single and bulk cases)
  • OnBefore{Entity}Delete() - Per-id variant; defaults to forwarding to OnBefore{Entity}ListDelete with a one-element list. Override only when the per-id flow genuinely diverges from the batch case.

Save Operation Hooks

  • OnBeforeSave{Entity}AndReturnMainUIFormDTO() - Validate or modify before save
  • OnAfterSave{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 uploads
  • OnBefore{Property}BlobFor{Entity}IsUploaded() - Process files before storage
  • ValidateImageFor{Property}Of{Entity}() - Custom image dimension validation
  • OptimizeImageFor{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 resolving ProductServiceGenerated returns your ProductService.
  • 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 email
  • OnAfterLogin(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})";
    }
}

These members are merged into the generated ProductDTO automatically — no [SpiderlyDTO] needed on the extension. Custom properties flow through to every generated artifact, including the Angular entity type (entities.generated.ts) and validators; methods stay server-side (only properties become TypeScript fields).

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);
        }
    }
}

Grouping entities under one controller with [Controller]

By default each entity's generated base controller is named {EntityName}BaseController and lives at its own route. Use [Controller("CustomName")] to share one controller across multiple related entities — useful when several entities form a single bounded context (e.g. user/role/permission all served by a security controller).

[Controller("SecurityController")]
public class User { }

[Controller("SecurityController")]
public class Role { }

[Controller("SecurityController")]
public class Permission { }

Suppressing the loading spinner with [SkipSpinner]

The Angular HTTP loading interceptor shows a global full-screen spinner for every request by default.

Most reads are already exempt automatically, so you rarely need this attribute: the generator skips the spinner for read-shaped responses (NamebookDTO, CodebookDTO, PaginatedResultDTO, LazyLoadSelectedIdsResultDTO) and for any [HttpGet] that returns a bare scalar (int, long, bool, decimal, DateTime, …) — for example an unread-notifications count.

Mark a controller method with [SkipSpinner] only when the inference can't see your intent: a [HttpGet] that returns a full DTO but is polled / refreshed on a timer, a background submit, an endpoint with its own inline loading UI, or a fetch feeding a lightweight popover / inline panel (e.g. a row-level "show order items" popover on a list page) where blocking the whole screen is disproportionate. Make this a deliberate decision for every endpoint you add — the inference covers the common cases, but only you know how the client consumes the response. The example below is a background void GET (which resolves to any, not a scalar, so it isn't auto-skipped):

[HttpGet]
[SkipSpinner]
public async Task SendNotificationEmail(long notificationId)
{
    await _emailingService.SendNotification(notificationId);
}

Forcing the spinner back on with [ShowSpinner]

[ShowSpinner] is the inverse: it forces the overlay ON for a call the inference would auto-skip. Explicit attributes always win over inference. You rarely need it — a user-triggered operation that does real work is usually a POST, which keeps the spinner without any attribute. Reach for [ShowSpinner] only for a deliberately slow [HttpGet] that returns a bare scalar where you still want the blocking overlay. Don't put it on the read-shaped DTO responses (autocomplete, dropdowns, table pagination) — a full-screen blackout on every keystroke or page change is a regression.

[HttpGet]
[ShowSpinner]
public async Task<int> RecalculateScore() // expensive, user-triggered
{
    return await _businessService.RecalculateScore();
}

Customizing What Gets Generated

The attributes below fine-tune the generated DTOs and service methods on a per-property basis.

[IncludeInDTO]

Forces a property to be included in the generated DTO. Use this for enumerable properties, which are excluded from DTOs by default. This attribute affects DTO shape only — it does not change mapping behavior.

[ExcludeFromDTO]

Excludes a property from all generated DTOs. The property still exists on the entity (and in the database), but is invisible to the API surface and Angular client.

[SpiderlyEntity]
public class User : BusinessObject<long>
{
    [DisplayName]
    public string Name { get; set; }

    [ExcludeFromDTO]
    public string InternalAuditNotes { get; set; } // Server-only, never sent to clients
}

[ReadOnly]

Marks a property as server-owned: clients can read it but never write it (the same idea as OpenAPI's readOnly). The property stays in the read {Entity}DTO — so it's still returned by GET endpoints and shown in the list table — but Spiderly closes its write path: no inbound validation rule is generated, the generated DTO→entity Mapster config .Ignore()s it (so a crafted payload can't set it), and it gets no editable control in the details form.

Use it for values only your backend assigns — usage counters, denormalized aggregates, computed timestamps. Pair it with [Required] to document the non-null column without generating an unsatisfiable rule: [Required] alone would emit a NotEmpty check the form can never satisfy (the form never sends the field), so every save would fail with a 422.

[SpiderlyEntity]
public class DiscountCode : BusinessObject<int>
{
    [DisplayName]
    public string Code { get; set; }

    [ReadOnly]
    [Required]
    public int TimesUsed { get; set; } // only backend code increments it; clients read only
}

Three attributes cover three different intents, so reach for the one that matches yours:

AttributeRead DTOWritableIn form
[ReadOnly]yesnono (read-only value shown in the list)
[UIDoNotGenerate]yesyeshidden (a custom UI or another flow fills it)
[ExcludeFromDTO]nonono (invisible to the whole API surface)

[ExcludeFromExcelExport]

Hides a property's column(s) from the generated Export{Entity}ListToExcel output, while keeping the property in the DTO and the rest of the API/UI. Use it for internal/technical columns that are noise in a human-readable sheet — raw foreign-key ids, payment-gateway correlation data, sync plumbing, and the like. (Enumerable/collection properties are excluded from the export automatically and don't need the attribute.)

Place it on the property that produces the column: a scalar property hides the matching column; a many-to-one navigation property hides the generated {Nav}DisplayName (and the synthesized {Nav}Id when there is no explicit foreign-key scalar).

[SpiderlyEntity]
public class Order : BusinessObject<long>
{
    [DisplayName]
    public string OrderNumber { get; set; }

    [ExcludeFromExcelExport]
    public string PaymentGatewayRrn { get; set; } // Kept in the API, hidden from the sheet

    [ExcludeFromExcelExport] // Hides the OrderStatusId column; OrderStatusDisplayName stays
    public byte OrderStatusId { get; set; }
    [WithMany(nameof(OrderStatus.Orders))]
    public virtual OrderStatus OrderStatus { get; set; }
}

Unlike [ExcludeFromDTO], the property is not removed from the DTO — it remains available to the admin grid, details views, and API consumers; only the spreadsheet column is dropped.

[ProjectToDTO]

Adds a custom Mapster projection step to the generated {Entity}DTO, executed when fetching a single entity. The attribute argument is the raw .Map(...) call appended to the projection config — pass the exact source/destination expression.

[ProjectToDTO] only fills a value — the destination property must already exist on the DTO (add it as a custom DTO property). It does not create the property, so mapping to a dest with no matching DTO property maps nothing and the field won't appear in the generated Angular client.

[ProjectToDTO(".Map(dest => dest.TransactionPrice, src => src.Transaction.Price)")]
public class Achievement : BusinessObject<long>
{
    [DisplayName]
    public string Name { get; set; }

    [WithMany(nameof(Transaction.Achievements))]
    public virtual Transaction Transaction { get; set; }
}

For more involved mapping logic, prefer overriding {Entity}ToDTOConfig() in your partial Mapper class — see Partial Classes → Custom Mappings (Mapper).

[ExcludeServiceMethodsFromGeneration]

Prevents the generator from emitting the standard CRUD service methods for the decorated property. Use this when you want full control over how the property is read or saved — e.g., a navigation that's loaded through a custom join, or a blob that goes through a non-standard upload path. The property still exists on the entity; only its generated service methods are skipped.

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)

InterfaceDescription
ITokenStorage<T>Token storage operations (in-memory, Redis, or custom)
IJwtAuthManagerJWT 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:

ConstantValuePurpose
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.

Generated save and delete operations also flush the change tracker for you before the transaction commits — so a hook (including OnBefore{Entity}Delete) can stage a tracked write such as IOutbox.Enqueue and have it persist atomically with the operation. This holds for deletes even though [CascadeDelete] removes dependents through untracked bulk ExecuteDeleteAsync. A manual SaveChangesAsync is only needed in custom (non-hook) WithTransactionAsync blocks (see below).

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.
});

WithTransactionAsync throws InvalidOperationException if the change tracker still has pending writes when the transaction is about to commit. transaction.CommitAsync() only commits the DB transaction — it does not flush the tracker — so a missed SaveChangesAsync after Add would otherwise silently drop the writes (a classic outbox-pattern footgun). In a custom (non-hook) block, either call SaveChangesAsync before returning from the action, or detach entries you don't intend to persist — generated save/delete operations already flush for you, so hooks don't need it.

Exception Types

TypeHTTP StatusWhen to Use
BusinessException(message)400Validation the user can trigger through normal UI usage
SecurityViolationException()403Impossible 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 client

Common 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();