Fading Coder

One Final Commit for the Last Sprint

Home > Tech > Content

Building CRUD with CodeSpirit: A Practical Employee Management Example

Tech May 10 4

Overview

This guide demonstrates how to rapidly develop CRUD functionality using the CodeSpirit framework, using a real‑world Employee management module from the CodeSpirit.IdentityApi sample. It covers entity design, data transfer objects, AutoMapper configuration, service layer, controller setup, database configuration, and migrations. Framework version: v2.0.0 | Last update: December 22, 2025

Development Workflow

1. Create Entity Model → 
2. Create DTO Classes → 
3. Configure AutoMapper → 
4. Create Service Layer → 
5. Create Controller → 
6. Configure Database Context → 
7. Create Migrations → 
8. Done

Module Characteristics

  • Association management: Department and User account
  • Full CRUD operations
  • Business validation: employee number uniqueness, department existence, ID card format
  • Multi‑condition search: keywords, department, status, date range
  • Form groups: basic info, contact, work details
  • Multi‑tenancy support
  • Automatic audit fields
  • Soft delete
  1. Entity Model

Place this class under Data/Models/Employee.cs:

using CodeSpirit.Shared.Entities.Interfaces;
using CodeSpirit.MultiTenant.Abstractions;
using System.ComponentModel.DataAnnotations;

namespace CodeSpirit.IdentityApi.Data.Models;

/// <summary>Employee entity</summary>
public class Employee : IFullAuditable, IMultiTenant, IIsActive
{
    public long Id { get; set; }

    [Required, MaxLength(50)]
    public string TenantId { get; set; } = string.Empty;

    [Required, MaxLength(50)]
    public string EmployeeNo { get; set; } = string.Empty;

    [Required, MaxLength(100)]
    public string Name { get; set; } = string.Empty;

    public Gender Gender { get; set; }

    [MaxLength(18)]
    public string? IdNo { get; set; }

    public DateTime? BirthDate { get; set; }

    [MaxLength(15)]
    public string? PhoneNumber { get; set; }

    [MaxLength(100), EmailAddress]
    public string? Email { get; set; }

    public long? DepartmentId { get; set; }
    public Department? Department { get; set; }

    [MaxLength(100)]
    public string? Position { get; set; }

    [MaxLength(50)]
    public string? JobLevel { get; set; }

    public DateTime? HireDate { get; set; }
    public DateTime? TerminationDate { get; set; }

    public EmploymentStatus EmploymentStatus { get; set; }

    public long? UserId { get; set; }
    public ApplicationUser? User { get; set; }

    [MaxLength(100)]
    public string? EmergencyContact { get; set; }

    [MaxLength(15)]
    public string? EmergencyPhone { get; set; }

    [MaxLength(500)]
    public string? Address { get; set; }

    [MaxLength(1000)]
    public string? Remarks { get; set; }

    public bool IsActive { get; set; } = true;

    [MaxLength(255), DataType(DataType.ImageUrl)]
    public string? AvatarUrl { get; set; }

    // Audit fields (IFullAuditable)
    public long CreatedBy { get; set; }
    public DateTime CreatedAt { get; set; }
    public long? UpdatedBy { get; set; }
    public DateTime? UpdatedAt { get; set; }
    public long? DeletedBy { get; set; }
    public DateTime? DeletedAt { get; set; }
    public bool IsDeleted { get; set; }
}

The entity implements IFullAuditable, IMultiTenant, and IIsActive. It includes navigation properties for Department and User, and supports soft delete.

  1. Data Transfer Objects (DTOs)

2.1 EmployeeDto (display)

using CodeSpirit.Amis.Attributes.Columns;
using CodeSpirit.Core.Attributes;
using CodeSpirit.IdentityApi.Data.Models;
using System.ComponentModel;

namespace CodeSpirit.IdentityApi.Dtos.Employee;

public class EmployeeDto
{
    public long Id { get; set; }

    [DisplayName("Employee No")]
    public string EmployeeNo { get; set; } = string.Empty;

    [DisplayName("Name")]
    [TplColumn(template: "${name}")]
    public string Name { get; set; } = string.Empty;

    [DisplayName("Avatar")]
    [AvatarColumn(Text = "${name}", Src = "${avatarUrl}")]
    [Badge(Animation = true, VisibleOn = "isActive", Level = "info")]
    public string? AvatarUrl { get; set; }

    [DisplayName("Gender")]
    public Gender Gender { get; set; }

    [DisplayName("Phone")]
    public string? PhoneNumber { get; set; }

    [DisplayName("Email")]
    public string? Email { get; set; }

    [AmisColumn(Hidden = true)]
    public long? DepartmentId { get; set; }

    [DisplayName("Department")]
    public string? DepartmentName { get; set; }

    [DisplayName("Position")]
    public string? Position { get; set; }

    [DisplayName("Job Level")]
    public string? JobLevel { get; set; }

    [DisplayName("Hire Date")]
    [DateColumn(Format = "YYYY-MM-DD")]
    public DateTime? HireDate { get; set; }

    [DisplayName("Status")]
    public EmploymentStatus EmploymentStatus { get; set; }

    [DisplayName("Active")]
    public bool IsActive { get; set; }

    [DisplayName("Created")]
    [DateColumn(FromNow = true)]
    public DateTime CreatedAt { get; set; }

    [DisplayName("Updated")]
    [DateColumn(FromNow = true)]
    public DateTime? UpdatedAt { get; set; }
}

Column attributes control table display: AmisColumn (hidden, sortable, fixed), TplColumn (custom template), AvatarColumn, DateColumn (format or relative time).

2.2 CreateEmployeeDto (creation form)

using CodeSpirit.Amis.Attributes.FormFields;
using CodeSpirit.IdentityApi.Data.Models;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;

namespace CodeSpirit.IdentityApi.Dtos.Employee;

[FormGroup("basic", "Basic Info", "EmployeeNo,Name,Gender,IdNo,BirthDate", Order = 1)]
[FormGroup("contact", "Contact", "PhoneNumber,Email,Address", Order = 2)]
[FormGroup("work", "Work Details", "DepartmentId,Position,JobLevel,HireDate,EmploymentStatus", Order = 3)]
[FormGroup("relation", "Association", "UserId", Order = 4)]
[FormGroup("emergency", "Emergency", "EmergencyContact,EmergencyPhone", Order = 5)]
[FormGroup("other", "Other", "AvatarUrl,Remarks,IsActive", Order = 6)]
public class CreateEmployeeDto
{
    [Required(ErrorMessage = "Employee number required")]
    [MaxLength(50)]
    [DisplayName("Employee No")]
    [AmisInputTextField(ColumnRatio = 6)]
    public string EmployeeNo { get; set; } = string.Empty;

    [Required]
    [MaxLength(100)]
    [DisplayName("Name")]
    [AmisInputTextField(ColumnRatio = 6)]
    public string Name { get; set; } = string.Empty;

    [DisplayName("Gender")]
    [AmisFormField(ColumnRatio = 6)]
    public Gender Gender { get; set; }

    [MaxLength(18)]
    [DisplayName("ID No")]
    [AmisInputTextField(ColumnRatio = 6)]
    public string? IdNo { get; set; }

    [DisplayName("Birth Date")]
    [AmisDateFieldAttribute(ColumnRatio = 6)]
    public DateTime? BirthDate { get; set; }

    [MaxLength(15)]
    [Phone]
    [DisplayName("Phone")]
    [AmisInputTextField(ColumnRatio = 6)]
    public string? PhoneNumber { get; set; }

    [MaxLength(100)]
    [EmailAddress]
    [DisplayName("Email")]
    [AmisInputTextField(ColumnRatio = 6)]
    public string? Email { get; set; }

    [DisplayName("Department")]
    [AmisInputTreeField(
        DataSource = "${ROOT_API}/api/identity/Departments/tree",
        LabelField = "name",
        ValueField = "id",
        Multiple = false,
        Searchable = true,
        ColumnRatio = 12
    )]
    public long? DepartmentId { get; set; }

    [MaxLength(100)]
    [DisplayName("Position")]
    [AmisInputTextField(ColumnRatio = 6)]
    public string? Position { get; set; }

    [MaxLength(50)]
    [DisplayName("Job Level")]
    [AmisInputTextField(ColumnRatio = 6)]
    public string? JobLevel { get; set; }

    [DisplayName("Hire Date")]
    [AmisDateFieldAttribute(ColumnRatio = 6)]
    public DateTime? HireDate { get; set; }

    [DisplayName("Status")]
    [AmisFormField(ColumnRatio = 6)]
    public EmploymentStatus EmploymentStatus { get; set; } = EmploymentStatus.Active;

    [DisplayName("User")]
    [AmisSelectField(
        Source = "${ROOT_API}/api/identity/Users",
        ValueField = "id",
        LabelField = "name",
        Multiple = false,
        Searchable = true,
        ColumnRatio = 12
    )]
    public long? UserId { get; set; }

    [MaxLength(100)]
    [DisplayName("Emergency Contact")]
    [AmisInputTextField(ColumnRatio = 6)]
    public string? EmergencyContact { get; set; }

    [MaxLength(15)]
    [Phone]
    [DisplayName("Emergency Phone")]
    [AmisInputTextField(ColumnRatio = 6)]
    public string? EmergencyPhone { get; set; }

    [MaxLength(500)]
    [DisplayName("Address")]
    [AmisTextareaField(ColumnRatio = 12)]
    public string? Address { get; set; }

    [MaxLength(255)]
    [DisplayName("Avatar")]
    [AmisInputImageField(
        Receiver = "/file/api/file/images/upload?BucketName=avatar",
        Accept = "image/png,image/jpeg,image/jpg",
        MaxSize = 2097152,
        Multiple = false,
        ColumnRatio = 12
    )]
    public string? AvatarUrl { get; set; }

    [MaxLength(1000)]
    [DisplayName("Remarks")]
    [AmisTextareaField(ColumnRatio = 12)]
    public string? Remarks { get; set; }

    [DisplayName("Active")]
    [AmisFormField(ColumnRatio = 6)]
    public bool IsActive { get; set; } = true;
}

Form attributes (FormGroup, AmisInputTextField, AmisInputTreeField, etc.) organize fields into groups and configure UI components. ColumnRatio controls width (12 = full, 6 = half).

2.3 UpdateEmployeeDto (update form)

The update DTO is similar to the creation DTO but includes TerminationDate and uses the same form‑group structure. The only difference is that EmploymentStatus is not defaulted (it must be provided). We omit the full code for brevity — it mirrors CreateEmployeeDto with the addition of TerminationDate and all fields required to be filled.

2.4 EmployeeQueryDto (search/filter)

using CodeSpirit.Amis.Attributes.FormFields;
using CodeSpirit.Core.Dtos;
using CodeSpirit.IdentityApi.Data.Models;
using System.ComponentModel;

namespace CodeSpirit.IdentityApi.Dtos.Employee;

public class EmployeeQueryDto : QueryDtoBase
{
    [DisplayName("Keywords")]
    public string? Keywords { get; set; }

    [DisplayName("Active")]
    public bool? IsActive { get; set; }

    [DisplayName("Gender")]
    public Gender? Gender { get; set; }

    [DisplayName("Department")]
    [AmisInputTreeField(
        DataSource = "${ROOT_API}/api/identity/Departments/tree",
        Multiple = false,
        LabelField = "name",
        ValueField = "id",
        Clearable = true,
        SubmitOnChange = true,
        HeightAuto = true,
        ShowOutline = true
    )]
    [PageAside]
    public long? DepartmentId { get; set; }

    [DisplayName("Employment Status")]
    public EmploymentStatus? EmploymentStatus { get; set; }

    [DisplayName("Hire Date")]
    public DateTime[]? HireDate { get; set; }

    [DisplayName("Position")]
    public string? Position { get; set; }

    [DisplayName("Job Level")]
    public string? JobLevel { get; set; }
}

QueryDtoBase provides paging/sorting properties. The [PageAside] attribute places the field in a sidebar (e.g., a department tree), and SubmitOnChange = true triggers automatic search when the field changes.

  1. AutoMapper Profile

using AutoMapper;
using CodeSpirit.IdentityApi.Data.Models;
using CodeSpirit.IdentityApi.Dtos.Employee;
using CodeSpirit.Shared.Extensions;

namespace CodeSpirit.IdentityApi.MappingProfiles;

public class EmployeeProfile : Profile
{
    public EmployeeProfile()
    {
        // Automatic CRUD mappings for base operations
        this.ConfigureBaseCRUDIMappings<
            Employee,
            EmployeeDto,
            long,
            CreateEmployeeDto,
            UpdateEmployeeDto,
            CreateEmployeeDto>();

        // Custom mappings: Denormalize navigation properties
        CreateMap<Employee, EmployeeDto>()
            .ForMember(dest => dest.DepartmentName, opt => opt.MapFrom(src => src.Department != null ? src.Department.Name : null))
            .ForMember(dest => dest.UserName, opt => opt.MapFrom(src => src.User != null ? src.User.UserName : null));
    }
}

The ConfigureBaseCRUDIMappings extension automatically handles most mapping needs. Additional ForMember calls map navigation properties to flat DTO fields.

  1. Service Interface and Implementation

4.1 Interface

using CodeSpirit.Core;
using CodeSpirit.IdentityApi.Data.Models;
using CodeSpirit.IdentityApi.Dtos.Employee;
using CodeSpirit.Shared.Services;

namespace CodeSpirit.IdentityApi.Services;

public interface IEmployeeService : IBaseCRUDIService<Employee, EmployeeDto, long, CreateEmployeeDto, UpdateEmployeeDto, EmployeeBatchImportItemDto>, IScopedDependency
{
    Task<PageList<EmployeeDto>> GetEmployeesAsync(EmployeeQueryDto queryDto);
    Task<List<EmployeeDto>> GetEmployeesByDepartmentAsync(long departmentId, bool includeSubDepartments = false);
    Task SetActiveStatusAsync(long id, bool isActive);
    Task TransferEmployeeAsync(long employeeId, long? newDepartmentId);
    Task TerminateEmployeeAsync(long employeeId, DateTime terminationDate);
    Task<bool> IsEmployeeNoUniqueAsync(string employeeNo, long? excludeId = null);
}

4.2 Implementation

using AutoMapper;
using CodeSpirit.Core;
using CodeSpirit.Core.IdGenerator;
using CodeSpirit.IdentityApi.Data;
using CodeSpirit.IdentityApi.Data.Models;
using CodeSpirit.IdentityApi.Dtos.Employee;
using CodeSpirit.Shared.Repositories;
using CodeSpirit.Shared.Services;
using LinqKit;
using Microsoft.EntityFrameworkCore;

namespace CodeSpirit.IdentityApi.Services;

public class EmployeeService : BaseCRUDIService<Employee, EmployeeDto, long, CreateEmployeeDto, UpdateEmployeeDto, EmployeeBatchImportItemDto>, IEmployeeService
{
    private readonly IRepository<Employee> _employeeRepo;
    private readonly IRepository<Department> _departmentRepo;
    private readonly IRepository<ApplicationUser> _userRepo;
    private readonly ICurrentUser _currentUser;
    private readonly IIdGenerator _idGen;

    public EmployeeService(IRepository<Employee> employeeRepo,
        IRepository<Department> departmentRepo,
        IRepository<ApplicationUser> userRepo,
        IMapper mapper,
        ILogger<EmployeeService> logger,
        IIdGenerator idGen,
        ICurrentUser currentUser,
        ApplicationDbContext dbContext,
        IDepartmentService departmentService,
        UserManager<ApplicationUser> userManager,
        EnhancedBatchImportHelper<EmployeeBatchImportItemDto> importHelper)
        : base(employeeRepo, mapper, importHelper)
    {
        _employeeRepo = employeeRepo;
        _departmentRepo = departmentRepo;
        _userRepo = userRepo;
        _currentUser = currentUser;
        _idGen = idGen;
    }

    public async Task<PageList<EmployeeDto>> GetEmployeesAsync(EmployeeQueryDto query)
    {
        var predicate = PredicateBuilder.New<Employee>(true);

        if (!string.IsNullOrWhiteSpace(query.Keywords))
        {
            var kw = query.Keywords.ToLower();
            predicate = predicate.Or(e => e.Name.ToLower().Contains(kw)
                || e.EmployeeNo.ToLower().Contains(kw)
                || e.IdNo.Contains(query.Keywords)
                || e.PhoneNumber.Contains(query.Keywords)
                || e.Email.ToLower().Contains(kw));
        }

        if (query.IsActive.HasValue)
            predicate = predicate.And(e => e.IsActive == query.IsActive);
        if (query.Gender.HasValue)
            predicate = predicate.And(e => e.Gender == query.Gender);
        if (query.DepartmentId.HasValue)
            predicate = predicate.And(e => e.DepartmentId == query.DepartmentId);
        if (query.EmploymentStatus.HasValue)
            predicate = predicate.And(e => e.EmploymentStatus == query.EmploymentStatus);
        if (!string.IsNullOrWhiteSpace(query.Position))
            predicate = predicate.And(e => e.Position == query.Position);
        if (!string.IsNullOrWhiteSpace(query.JobLevel))
            predicate = predicate.And(e => e.JobLevel == query.JobLevel);
        if (query.HireDate != null && query.HireDate.Length == 2)
        {
            predicate = predicate.And(e => e.HireDate >= query.HireDate[0] && e.HireDate <= query.HireDate[1]);
        }

        var queryable = _employeeRepo.CreateQuery()
            .Include(e => e.Department)
            .Include(e => e.User)
            .Where(predicate);

        var total = await queryable.CountAsync();
        var items = await queryable
            .OrderByDescending(e => e.CreatedAt)
            .Skip((query.Page - 1) * query.PerPage)
            .Take(query.PerPage)
            .ToListAsync();

        var dtos = Mapper.Map<List<EmployeeDto>>(items);
        // Fill department/user names
        foreach (var dto in dtos)
        {
            var emp = items.First(e => e.Id == dto.Id);
            dto.DepartmentName = emp.Department?.Name;
            dto.UserName = emp.User?.UserName;
        }
        return new PageList<EmployeeDto>(dtos, total);
    }

    public async Task SetActiveStatusAsync(long id, bool active)
    {
        var emp = await _employeeRepo.GetByIdAsync(id);
        if (emp == null) throw new AppServiceException(404, "Employee not found");
        emp.IsActive = active;
        await _employeeRepo.UpdateAsync(emp);
    }

    // Other methods follow similar patterns; omitted for brevity.

    protected override async Task ValidateCreateDto(CreateEmployeeDto dto)
    {
        await base.ValidateCreateDto(dto);
        if (!await IsEmployeeNoUniqueAsync(dto.EmployeeNo))
            throw new AppServiceException(400, $"Employee number {dto.EmployeeNo} already exists");
        if (dto.DepartmentId.HasValue && !await _departmentRepo.ExistsAsync(d => d.Id == dto.DepartmentId.Value))
            throw new AppServiceException(400, "Department not found");
        if (dto.UserId.HasValue && !await _userRepo.ExistsAsync(u => u.Id == dto.UserId.Value))
            throw new AppServiceException(400, "User not found");
    }

    protected override async Task ValidateUpdateDto(long id, UpdateEmployeeDto dto)
    {
        await base.ValidateUpdateDto(id, dto);
        if (!await IsEmployeeNoUniqueAsync(dto.EmployeeNo, id))
            throw new AppServiceException(400, $"Employee number {dto.EmployeeNo} already exists");
        if (dto.DepartmentId.HasValue && !await _departmentRepo.ExistsAsync(d => d.Id == dto.DepartmentId.Value))
            throw new AppServiceException(400, "Department not found");
    }

    protected override async Task<Employee> OnCreating(CreateEmployeeDto dto)
    {
        var emp = await base.OnCreating(dto);
        emp.TenantId = _currentUser.TenantId;
        if (emp.Id == 0) emp.Id = await _idGen.GenerateIdAsync();
        return emp;
    }
}
  1. Controller

using CodeSpirit.Core;
using CodeSpirit.Core.Attributes;
using CodeSpirit.Core.Dtos;
using CodeSpirit.Core.Enums;
using CodeSpirit.IdentityApi.Dtos.Employee;
using CodeSpirit.IdentityApi.Services;
using Microsoft.AspNetCore.Mvc;
using System.ComponentModel;

namespace CodeSpirit.IdentityApi.Controllers;

[DisplayName("Employee Management")]
[Navigation(Icon = "fa-solid fa-user-tie", PlatformType = PlatformType.Tenant)]
public class EmployeesController : ApiControllerBase
{
    private readonly IEmployeeService _service;
    public EmployeesController(IEmployeeService service) => _service = service;

    [HttpGet]
    public async Task<ActionResult<ApiResponse<PageList<EmployeeDto>>>> GetEmployees([FromQuery] EmployeeQueryDto query)
        => SuccessResponse(await _service.GetEmployeesAsync(query));

    [HttpGet("department/{departmentId}")]
    public async Task<ActionResult<ApiResponse<List<EmployeeDto>>>> GetByDepartment(long departmentId, [FromQuery] bool includeSub = false)
        => SuccessResponse(await _service.GetEmployeesByDepartmentAsync(departmentId, includeSub));

    [HttpGet("{id:long}")]
    public async Task<ActionResult<ApiResponse<EmployeeDto>>> Get(long id)
        => SuccessResponse(await _service.GetAsync(id));

    [HttpPost]
    public async Task<ActionResult<ApiResponse<EmployeeDto>>> Create(CreateEmployeeDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);
        return SuccessResponse(await _service.CreateAsync(dto));
    }

    [HttpPut("{id:long}")]
    public async Task<ActionResult<ApiResponse>> Update(long id, UpdateEmployeeDto dto)
    {
        await _service.UpdateAsync(id, dto);
        return SuccessResponse();
    }

    [HttpDelete("{id:long}")]
    [Operation("Delete", "ajax", null, "Are you sure you want to delete this employee?")]
    public async Task<ActionResult<ApiResponse>> Delete(long id)
    {
        await _service.DeleteAsync(id);
        return SuccessResponse();
    }

    [HttpPost("batch-delete")]
    [Operation("Batch Delete", "ajax", null, "Are you sure you want to batch delete the selected employees?", isBulkOperation: true)]
    public async Task<ActionResult<ApiResponse>> BatchDelete([FromBody] BatchOperationDto<long> request)
    {
        ArgumentNullException.ThrowIfNull(request);
        var (success, failed) = await _service.BatchDeleteAsync(request.Ids);
        return failed.Any()
            ? SuccessResponse($"Deleted {success} employees; failed: {string.Join(", ", failed)}")
            : SuccessResponse($"Successfully deleted {success} employees.");
    }

    [HttpPut("{id:long}/active")]
    public async Task<ActionResult<ApiResponse>> SetActive(long id, [FromBody] bool active)
    {
        await _service.SetActiveStatusAsync(id, active);
        return SuccessResponse();
    }

    [HttpPut("{id:long}/transfer")]
    public async Task<ActionResult<ApiResponse>> Transfer(long id, [FromBody] TransferEmployeeRequest req)
    {
        await _service.TransferEmployeeAsync(id, req.DepartmentId);
        return SuccessResponse();
    }

    [HttpPut("{id:long}/terminate")]
    public async Task<ActionResult<ApiResponse>> Terminate(long id, [FromBody] TerminateEmployeeRequest req)
    {
        await _service.TerminateEmployeeAsync(id, req.TerminationDate);
        return SuccessResponse();
    }
}

public record TransferEmployeeRequest(long? DepartmentId);
public record TerminateEmployeeRequest(DateTime TerminationDate);

The controller inherits from ApiControllerBase to get unified responses and exception hnadling. DisplayName and Navigation provide UI metadata; Operation configures delete confirmation dialogs.

  1. Database Context Configuration

using CodeSpirit.IdentityApi.Data.Models;
using CodeSpirit.Shared.Data;
using Microsoft.EntityFrameworkCore;

namespace CodeSpirit.IdentityApi.Data;

public class ApplicationDbContext : MultiDatabaseDbContextBase
{
    public DbSet<Employee> Employees => Set<Employee>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);

        modelBuilder.Entity<Employee>(entity =>
        {
            entity.ToTable(nameof(Employee));
            entity.Property(e => e.Id).ValueGeneratedNever();

            // Tenant-scoped unique employee number
            entity.HasIndex(e => new { e.TenantId, e.EmployeeNo })
                .IsUnique()
                .HasDatabaseName("IX_Employee_TenantId_EmployeeNo");

            // Performance indexes
            entity.HasIndex(e => e.DepartmentId).HasDatabaseName("IX_Employee_DepartmentId");
            entity.HasIndex(e => e.UserId).HasDatabaseName("IX_Employee_UserId");
            entity.HasIndex(e => e.IsActive).HasDatabaseName("IX_Employee_IsActive");
            entity.HasIndex(e => e.EmploymentStatus).HasDatabaseName("IX_Employee_EmploymentStatus");

            // Relationships
            entity.HasOne(e => e.Department)
                .WithMany()
                .HasForeignKey(e => e.DepartmentId)
                .OnDelete(DeleteBehavior.SetNull);

            entity.HasOne(e => e.User)
                .WithMany()
                .HasForeignKey(e => e.UserId)
                .OnDelete(DeleteBehavior.SetNull);
        });
    }
}

Inheritance from MultiDatabaseDbContextBase supports both MySQL and SQL Server. The composite index (TenantId, EmployeeNo) ensures uniqueness within a tenant.

  1. Service Registration

CodeSpirit automatically registers services that implement marker interfaces. Because IEmployeeService extends IScopedDependency, no manual registration in Program.cs is required.

  1. Creating Migrations

Use the Entity Framework CLI with database‑specific contexts and output directories:

# MySQL migration
dotnet ef migrations add AddEmployees --context MySqlApplicationDbContext --output-dir Migrations/MySql

# SQL Server migration
dotnet ef migrations add AddEmployees --context SqlServerApplicationDbContext --output-dir Migrations/SqlServer

# Apply migrations
dotnet ef database update --context MySqlApplicationDbContext

The migration files are stored separately under Migrations/MySql/ and Migrations/SqlServer/.

Feature Highlights

  • Automatic UI generation – Tables (avatars, date formatting, status badges), forms (groups, tree selectors, image uplaoders), advanced search, and batch operations.
  • Unified API responses using ApiResponse<T>.
  • Pagination, sorting, multi‑condition filtering.
  • Batch operations (delete, import).
  • Global exception handling.
  • Permission control via attributes.
  • Audit logging – automatically records create/update/delete.
  • Multi‑tenancy – data isolation by tenant ID.
  • Soft delete.

Business Validation Examples

Create Validation

protected override async Task ValidateCreateDto(CreateEmployeeDto dto)
{
    await base.ValidateCreateDto(dto);
    if (!await IsEmployeeNoUniqueAsync(dto.EmployeeNo))
        throw new AppServiceException(400, $"Employee number {dto.EmployeeNo} already exists");
    // ... other checks
}

Update Validation

protected override async Task ValidateUpdateDto(long id, UpdateEmployeeDto dto)
{
    await base.ValidateUpdateDto(id, dto);
    if (!await IsEmployeeNoUniqueAsync(dto.EmployeeNo, id))
        throw new AppServiceException(400, $"Employee number {dto.EmployeeNo} already exists");
}

Pre‑creation Processing

protected override async Task<Employee> OnCreating(CreateEmployeeDto dto)
{
    var emp = await base.OnCreating(dto);
    emp.TenantId = _currentUser.TenantId;
    if (emp.Id == 0) emp.Id = await _idGen.GenerateIdAsync();
    return emp;
}

Best Practices

  • Entity design: Implement IFullAuditable, IMultiTenant, IIsActive; create composite unique indexes for tenant‑scoped business keys.
  • DTO separation: Use distinct DTOs for create, update, and query. Leverage column/form attributes to control UI rendering.
  • Service layer: Extend BaseCRUDIService; override validation hooks; use PredicateBuilder for dynamic filters.
  • Controller: Keep it thin; use DisplayName, Navigation, Operation attributes for UI integration.
  • Validation: Combine DataAnnotations with service‑level checks; use AppServiceException for business errors.
  • Database: Add indexes for frequently queried columns; choose appropriate delete behavior for relationships.
  • Documentation: Provide XML comments for all public members.

Related Documentation

  • CodeSpirit.Core framework
  • Development environment setup
  • Project architecture overview
  • Unified exception handling guide

Related Articles

Understanding Strong and Weak References in Java

Strong References Strong reference are the most prevalent type of object referencing in Java. When an object has a strong reference pointing to it, the garbage collector will not reclaim its memory. F...

Comprehensive Guide to SSTI Explained with Payload Bypass Techniques

Introduction Server-Side Template Injection (SSTI) is a vulnerability in web applications where user input is improper handled within the template engine and executed on the server. This exploit can r...

Implement Image Upload Functionality for Django Integrated TinyMCE Editor

Django’s Admin panel is highly user-friendly, and pairing it with TinyMCE, an effective rich text editor, simplifies content management significantly. Combining the two is particular useful for bloggi...

Leave a Comment

Anonymous

◎Feel free to join the discussion and share your thoughts.