Skip to content

Project 3 — Production CRM Platform ​

Tóm tắt:

Abstract (capstone). Tổng hợp Stage 4 thành nền tảng CRM sẵn sàng vận hành: quan sát (observability), khả năng phục hồi, tầng dữ liệu và tự động hóa triển khai — mô phỏng Definition of Done cấp platform.

Phương pháp học tập ​

Bài học này được dẫn dắt theo chu trình học tập chủ động:

  1. Khởi động tư duy: bắt đầu bằng câu hỏi gợi mở để kích hoạt kiến thức nền.
  2. Kiến tạo kiến thức: học khái niệm cốt lõi đi kèm ví dụ có ngữ cảnh.
  3. Luyện tập có hướng dẫn: áp dụng qua mini case và ví dụ thực tế ngắn.
  4. Tự đánh giá và phản tư: dùng checklist + bài thực hành để chốt năng lực.

Mục tiêu là bạn hiểu sâu, dùng được ngay, và tự đánh giá được mức độ nắm bài.

Câu hỏi khởi động ​

  • Nếu không có kiến thức trong bài này, hệ thống CRM/ERP sẽ gặp rủi ro gì?
  • Trong dự án thực tế, bạn nghĩ phần kiến thức này nằm ở tầng nào (API, nghiệp vụ, dữ liệu, vận hành)?
  • Dấu hiệu nào cho thấy bạn đã hiểu bản chất bài học (không chỉ nhớ định nghĩa)?

1. Tổng quan dự án ​

Từ Project 2 lên Project 3 ​

Project 2 đã xây nền tảng CRM với in-memory data, single-tenant, và API cơ bản. Project 3 là bước chuyển sang production-ready platform — nơi mọi quyết định thiết kế đều phải tính đến scale, isolation, reliability và observability.

Tiêu chíProject 2Project 3
Lưu trữ dữ liệuIn-memory (mất khi restart)SQL Server / PostgreSQL (persistent)
TenantSingle tenantMulti-tenant (row-level isolation)
CacheKhông cóRedis distributed cache
Background jobsKhông cóHangfire + recurring jobs
NotificationKhông cóSignalR real-time + persist DB
AuditKhông cóFull audit log (who/what/when)
Deploymentdotnet runDocker Compose (API + DB + Redis + Hangfire)
AuthJWT đơn giảnJWT + TenantId claim + role-based
ApprovalKhông cóWorkflow engine: Lead → Deal
ExportKhông cóCSV/Excel + background job + presigned URL
TestUnit test cơ bảnIntegration test + E2E + load test
ObservabilityConsole logStructured log + health checks

Tại sao multi-tenant? ​

Phần lớn SaaS thực tế là multi-tenant. Hiểu được isolation strategy giúp bạn làm việc được ngay với các hệ thống enterprise. Project này chọn row-level isolation — đơn giản nhất để học nhưng vẫn phản ánh đúng vấn đề thực tế.


2. Mục tiêu học tập ​

Sau khi hoàn thành Project 3, học viên có thể:

  1. Thiết kế và implement multi-tenant database với row-level isolation, global query filter, và tenant resolution từ JWT claim.
  2. Viết EF Core migrations cho schema production, bao gồm indexes, constraints, và seed data.
  3. Tích hợp Redis làm distributed cache với cache-aside pattern, TTL strategy, và cache invalidation.
  4. Xây dựng Hangfire jobs bao gồm fire-and-forget, recurring, và continuations; xử lý retry và dead-letter.
  5. Implement SignalR để push notification real-time đến đúng tenant/user mà không leak data cross-tenant.
  6. Thiết kế Approval Workflow với state machine đơn giản, transition validation, và event notification.
  7. Viết Docker Compose production-like với health checks, environment variables, volume mounts, và network isolation.
  8. Đo và tối ưu performance: đặt mục tiêu p95, profiling query N+1, và viết load test với k6.

3. Multi-tenant Architecture ​

Ba chiến lược isolation ​

StrategyƯu điểmNhược điểmDùng khi
Database per tenantIsolation tuyệt đối, backup riêngChi phí cao, khó scaleEnterprise, regulated industry
Schema per tenantIsolation tốt, cùng enginePhức tạp migration, giới hạn schemaMid-market SaaS
Row-level (discriminator)Đơn giản, scale tốtCần filter mọi query, rủi ro data leakStartup, SMB SaaS

Project này dùng row-level isolation với TenantId trên mọi entity. EF Core Global Query Filter đảm bảo mọi query đều tự động filter đúng tenant.

TenantId trên Entity ​

csharp
// Domain/Common/BaseEntity.cs
public abstract class TenantEntity
{
    public Guid Id { get; set; }
    public Guid TenantId { get; set; }
    public DateTimeOffset CreatedAt { get; set; }
    public DateTimeOffset? UpdatedAt { get; set; }
    public string CreatedBy { get; set; } = string.Empty;
    public string? UpdatedBy { get; set; }
    public bool IsDeleted { get; set; }
}

// Domain/Entities/Lead.cs
public class Lead : TenantEntity
{
    public string FullName { get; set; } = string.Empty;
    public string Email { get; set; } = string.Empty;
    public string? Phone { get; set; }
    public LeadStatus Status { get; set; }
    public Guid? AssignedToUserId { get; set; }
    public AppUser? AssignedTo { get; set; }
    public ICollection<Activity> Activities { get; set; } = new List<Activity>();
    public ICollection<Note> Notes { get; set; } = new List<Note>();
}

ITenantContext và Middleware ​

csharp
// Application/Interfaces/ITenantContext.cs
public interface ITenantContext
{
    Guid TenantId { get; }
    string TenantName { get; }
    bool IsResolved { get; }
}

// Infrastructure/Multitenancy/JwtTenantContext.cs
public class JwtTenantContext : ITenantContext
{
    public Guid TenantId { get; private set; }
    public string TenantName { get; private set; } = string.Empty;
    public bool IsResolved { get; private set; }

    public JwtTenantContext(IHttpContextAccessor accessor)
    {
        var user = accessor.HttpContext?.User;
        var claim = user?.FindFirst("tenant_id")?.Value;
        if (Guid.TryParse(claim, out var id))
        {
            TenantId = id;
            TenantName = user?.FindFirst("tenant_name")?.Value ?? string.Empty;
            IsResolved = true;
        }
    }
}

Global Query Filter trong DbContext ​

csharp
// Infrastructure/Persistence/CrmDbContext.cs
public class CrmDbContext : DbContext
{
    private readonly ITenantContext _tenant;

    public CrmDbContext(DbContextOptions<CrmDbContext> options, ITenantContext tenant)
        : base(options)
    {
        _tenant = tenant;
    }

    public DbSet<Lead> Leads => Set<Lead>();
    public DbSet<Deal> Deals => Set<Deal>();
    public DbSet<Contact> Contacts => Set<Contact>();
    public DbSet<Activity> Activities => Set<Activity>();
    public DbSet<Notification> Notifications => Set<Notification>();
    public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
    public DbSet<ApprovalRequest> ApprovalRequests => Set<ApprovalRequest>();

    protected override void OnModelCreating(ModelBuilder builder)
    {
        // Global query filter — áp dụng cho mọi entity kế thừa TenantEntity
        foreach (var entityType in builder.Model.GetEntityTypes())
        {
            if (typeof(TenantEntity).IsAssignableFrom(entityType.ClrType))
            {
                var method = typeof(CrmDbContext)
                    .GetMethod(nameof(ApplyTenantFilter),
                        BindingFlags.NonPublic | BindingFlags.Static)!
                    .MakeGenericMethod(entityType.ClrType);
                method.Invoke(null, new object[] { builder, _tenant });
            }
        }
        builder.ApplyConfigurationsFromAssembly(typeof(CrmDbContext).Assembly);
    }

    private static void ApplyTenantFilter<T>(ModelBuilder builder, ITenantContext tenant)
        where T : TenantEntity
    {
        builder.Entity<T>().HasQueryFilter(e =>
            !e.IsDeleted && e.TenantId == tenant.TenantId);
    }
}

4. System Architecture ​

Diagram tổng quan ​

                        ┌─────────────────────────────────────────┐
                        │            Docker Network: crm-net       │
                        │                                          │
  Browser / Mobile ───► │  Nginx (80/443)                          │
                        │      │                                   │
                        │      ▼                                   │
                        │  API (.NET 8)  ──────► SQL Server :1433  │
                        │      │         ──────► Redis      :6379  │
                        │      │                                   │
                        │  Hangfire Dashboard                      │
                        │      │         ──────► SQL Server        │
                        │      │         ──────► Redis             │
                        │                                          │
                        │  SignalR Hub (WebSocket)                 │
                        └─────────────────────────────────────────┘

Folder Structure (Clean Architecture Lite) ​

CrmPlatform/
├── src/
│   ├── CrmPlatform.Api/                  # Presentation layer
│   │   ├── Controllers/
│   │   │   ├── LeadsController.cs
│   │   │   ├── DealsController.cs
│   │   │   ├── ApprovalsController.cs
│   │   │   ├── NotificationsController.cs
│   │   │   └── DashboardController.cs
│   │   ├── Hubs/
│   │   │   └── NotificationHub.cs
│   │   ├── Middleware/
│   │   │   └── TenantResolutionMiddleware.cs
│   │   └── Program.cs
│   │
│   ├── CrmPlatform.Application/          # Use cases
│   │   ├── Features/
│   │   │   ├── Leads/
│   │   │   ├── Deals/
│   │   │   ├── Approvals/
│   │   │   └── Notifications/
│   │   ├── Interfaces/
│   │   │   ├── ITenantContext.cs
│   │   │   ├── ICurrentUser.cs
│   │   │   └── INotificationService.cs
│   │   └── Common/
│   │       └── Result.cs
│   │
│   ├── CrmPlatform.Domain/               # Entities + business rules
│   │   ├── Entities/
│   │   ├── Enums/
│   │   └── Events/
│   │
│   └── CrmPlatform.Infrastructure/       # EF Core, Redis, Hangfire, Email
│       ├── Persistence/
│       │   ├── CrmDbContext.cs
│       │   ├── Migrations/
│       │   └── Configurations/
│       ├── Caching/
│       ├── Jobs/
│       ├── Notifications/
│       └── Multitenancy/
│
├── tests/
│   ├── CrmPlatform.IntegrationTests/
│   └── CrmPlatform.LoadTests/            # k6 scripts
│
└── docker-compose.yml

5. Database Schema ​

SQL Schema chính ​

sql
-- Tenants
CREATE TABLE Tenants (
    Id          UNIQUEIDENTIFIER PRIMARY KEY DEFAULT NEWSEQUENTIALID(),
    Name        NVARCHAR(200) NOT NULL,
    Slug        VARCHAR(100) NOT NULL UNIQUE,
    Plan        VARCHAR(50) NOT NULL DEFAULT 'free',
    IsActive    BIT NOT NULL DEFAULT 1,
    CreatedAt   DATETIMEOFFSET NOT NULL DEFAULT SYSDATETIMEOFFSET()
);

-- Users (per tenant)
CREATE TABLE AppUsers (
    Id          UNIQUEIDENTIFIER PRIMARY KEY DEFAULT NEWSEQUENTIALID(),
    TenantId    UNIQUEIDENTIFIER NOT NULL REFERENCES Tenants(Id),
    Email       NVARCHAR(200) NOT NULL,
    FullName    NVARCHAR(200) NOT NULL,
    Role        VARCHAR(50) NOT NULL DEFAULT 'sales',
    IsActive    BIT NOT NULL DEFAULT 1,
    CreatedAt   DATETIMEOFFSET NOT NULL DEFAULT SYSDATETIMEOFFSET(),
    INDEX IX_AppUsers_TenantId (TenantId)
);

-- Leads
CREATE TABLE Leads (
    Id              UNIQUEIDENTIFIER PRIMARY KEY DEFAULT NEWSEQUENTIALID(),
    TenantId        UNIQUEIDENTIFIER NOT NULL REFERENCES Tenants(Id),
    FullName        NVARCHAR(200) NOT NULL,
    Email           NVARCHAR(200),
    Phone           NVARCHAR(50),
    Status          VARCHAR(50) NOT NULL DEFAULT 'new',
    Source          VARCHAR(100),
    AssignedToUserId UNIQUEIDENTIFIER REFERENCES AppUsers(Id),
    CreatedBy       NVARCHAR(200) NOT NULL,
    CreatedAt       DATETIMEOFFSET NOT NULL DEFAULT SYSDATETIMEOFFSET(),
    UpdatedAt       DATETIMEOFFSET,
    IsDeleted       BIT NOT NULL DEFAULT 0,
    INDEX IX_Leads_TenantId_Status (TenantId, Status),
    INDEX IX_Leads_TenantId_AssignedTo (TenantId, AssignedToUserId)
);

-- Deals (chuyển từ Lead)
CREATE TABLE Deals (
    Id              UNIQUEIDENTIFIER PRIMARY KEY DEFAULT NEWSEQUENTIALID(),
    TenantId        UNIQUEIDENTIFIER NOT NULL REFERENCES Tenants(Id),
    LeadId          UNIQUEIDENTIFIER REFERENCES Leads(Id),
    Title           NVARCHAR(300) NOT NULL,
    Value           DECIMAL(18,2),
    Currency        VARCHAR(10) DEFAULT 'VND',
    Stage           VARCHAR(50) NOT NULL DEFAULT 'qualification',
    ApprovalStatus  VARCHAR(50) NOT NULL DEFAULT 'pending',
    AssignedToUserId UNIQUEIDENTIFIER REFERENCES AppUsers(Id),
    ClosedAt        DATETIMEOFFSET,
    CreatedBy       NVARCHAR(200) NOT NULL,
    CreatedAt       DATETIMEOFFSET NOT NULL DEFAULT SYSDATETIMEOFFSET(),
    UpdatedAt       DATETIMEOFFSET,
    IsDeleted       BIT NOT NULL DEFAULT 0,
    INDEX IX_Deals_TenantId_Stage (TenantId, Stage),
    INDEX IX_Deals_TenantId_ApprovalStatus (TenantId, ApprovalStatus)
);

-- Approval Requests
CREATE TABLE ApprovalRequests (
    Id              UNIQUEIDENTIFIER PRIMARY KEY DEFAULT NEWSEQUENTIALID(),
    TenantId        UNIQUEIDENTIFIER NOT NULL REFERENCES Tenants(Id),
    EntityType      VARCHAR(50) NOT NULL,  -- 'Deal', 'Discount', ...
    EntityId        UNIQUEIDENTIFIER NOT NULL,
    RequestedBy     UNIQUEIDENTIFIER NOT NULL REFERENCES AppUsers(Id),
    ReviewedBy      UNIQUEIDENTIFIER REFERENCES AppUsers(Id),
    Status          VARCHAR(50) NOT NULL DEFAULT 'pending',
    Note            NVARCHAR(1000),
    RequestedAt     DATETIMEOFFSET NOT NULL DEFAULT SYSDATETIMEOFFSET(),
    ReviewedAt      DATETIMEOFFSET,
    IsDeleted       BIT NOT NULL DEFAULT 0,
    INDEX IX_Approvals_TenantId_Status (TenantId, Status)
);

-- Audit Log
CREATE TABLE AuditLogs (
    Id          BIGINT IDENTITY PRIMARY KEY,
    TenantId    UNIQUEIDENTIFIER NOT NULL,
    UserId      UNIQUEIDENTIFIER,
    EntityType  VARCHAR(100) NOT NULL,
    EntityId    NVARCHAR(100) NOT NULL,
    Action      VARCHAR(50) NOT NULL,  -- 'Create', 'Update', 'Delete', 'StatusChange'
    OldValue    NVARCHAR(MAX),
    NewValue    NVARCHAR(MAX),
    ChangedAt   DATETIMEOFFSET NOT NULL DEFAULT SYSDATETIMEOFFSET(),
    IpAddress   VARCHAR(50),
    INDEX IX_AuditLogs_TenantId_EntityType (TenantId, EntityType),
    INDEX IX_AuditLogs_TenantId_ChangedAt (TenantId, ChangedAt)
);

-- Notifications
CREATE TABLE Notifications (
    Id          UNIQUEIDENTIFIER PRIMARY KEY DEFAULT NEWSEQUENTIALID(),
    TenantId    UNIQUEIDENTIFIER NOT NULL,
    UserId      UNIQUEIDENTIFIER NOT NULL REFERENCES AppUsers(Id),
    Type        VARCHAR(100) NOT NULL,
    Title       NVARCHAR(300) NOT NULL,
    Body        NVARCHAR(1000),
    IsRead      BIT NOT NULL DEFAULT 0,
    Payload     NVARCHAR(MAX),
    CreatedAt   DATETIMEOFFSET NOT NULL DEFAULT SYSDATETIMEOFFSET(),
    INDEX IX_Notifications_UserId_IsRead (UserId, IsRead)
);

Migration Strategy ​

bash
# Tạo migration ban đầu
dotnet ef migrations add InitialCreate \
  --project src/CrmPlatform.Infrastructure \
  --startup-project src/CrmPlatform.Api

# Apply migration khi startup (Development)
# Trong Program.cs:
# app.MigrateDatabase<CrmDbContext>();

# Production: dùng migration bundle
dotnet ef migrations bundle --self-contained -r linux-x64 \
  --project src/CrmPlatform.Infrastructure \
  --startup-project src/CrmPlatform.Api

Seed Data Script ​

csharp
// Infrastructure/Persistence/SeedData.cs
public static class SeedData
{
    public static async Task SeedAsync(CrmDbContext context)
    {
        if (await context.Tenants.AnyAsync()) return;

        var demoTenant = new Tenant
        {
            Id = Guid.Parse("11111111-0000-0000-0000-000000000001"),
            Name = "Demo Company",
            Slug = "demo",
            Plan = "pro",
            IsActive = true
        };
        context.Tenants.Add(demoTenant);

        var adminUser = new AppUser
        {
            Id = Guid.Parse("22222222-0000-0000-0000-000000000001"),
            TenantId = demoTenant.Id,
            Email = "[email protected]",
            FullName = "Admin Demo",
            Role = "manager"
        };
        context.AppUsers.Add(adminUser);

        await context.SaveChangesAsync();
    }
}

6. Feature Specifications ​

6.1 Approval Engine ​

Lead khi đạt trạng thái qualified cần được chuyển thành Deal. Deal có value > ngưỡng (ví dụ: 50 triệu VND) cần manager phê duyệt trước khi tiến sang negotiation.

State Machine:

Lead: new → contacted → qualified ──────────────────► Deal: qualification
                                                              │
                                                              ▼
                                          ApprovalRequest: pending
                                                   │          │
                                               approved    rejected
                                                   │          │
                                                   ▼          ▼
                                          Deal: negotiation  Deal: lost

Implement:

csharp
// Application/Features/Approvals/ApproveOrRejectCommand.cs
public record ApproveOrRejectCommand(
    Guid ApprovalRequestId,
    bool IsApproved,
    string? Note
) : IRequest<Result>;

public class ApproveOrRejectHandler : IRequestHandler<ApproveOrRejectCommand, Result>
{
    public async Task<Result> Handle(ApproveOrRejectCommand cmd, CancellationToken ct)
    {
        var request = await _context.ApprovalRequests
            .Include(r => r.Deal)
            .FirstOrDefaultAsync(r => r.Id == cmd.ApprovalRequestId, ct);

        if (request is null) return Result.Failure("Không tìm thấy approval request");
        if (request.Status != ApprovalStatus.Pending)
            return Result.Failure("Request đã được xử lý");

        request.Status = cmd.IsApproved ? ApprovalStatus.Approved : ApprovalStatus.Rejected;
        request.ReviewedBy = _currentUser.UserId;
        request.ReviewedAt = DateTimeOffset.UtcNow;
        request.Note = cmd.Note;

        if (request.Deal is not null)
        {
            request.Deal.ApprovalStatus = cmd.IsApproved
                ? DealApprovalStatus.Approved
                : DealApprovalStatus.Rejected;

            if (cmd.IsApproved)
                request.Deal.Stage = DealStage.Negotiation;
        }

        await _context.SaveChangesAsync(ct);

        // Gửi notification cho người yêu cầu
        await _notificationService.SendAsync(new SendNotificationRequest
        {
            TenantId = _tenant.TenantId,
            UserId = request.RequestedBy,
            Type = "approval_result",
            Title = cmd.IsApproved ? "Deal đã được phê duyệt" : "Deal bị từ chối",
            Body = cmd.Note
        });

        return Result.Success();
    }
}

6.2 Background Jobs với Hangfire ​

csharp
// Infrastructure/Jobs/CrmJobs.cs
public class CrmJobs
{
    private readonly CrmDbContext _context;
    private readonly IEmailService _email;

    // Chạy mỗi sáng 8h — tóm tắt hoạt động hôm trước
    [AutomaticRetry(Attempts = 3)]
    public async Task SendDailyDigestAsync(Guid tenantId)
    {
        var yesterday = DateTimeOffset.UtcNow.AddDays(-1).Date;
        var stats = await _context.Leads
            .IgnoreQueryFilters()  // Chú ý: phải filter thủ công khi IgnoreQueryFilters
            .Where(l => l.TenantId == tenantId && l.CreatedAt.Date == yesterday)
            .GroupBy(l => l.Status)
            .Select(g => new { Status = g.Key, Count = g.Count() })
            .ToListAsync();

        var users = await _context.AppUsers
            .IgnoreQueryFilters()
            .Where(u => u.TenantId == tenantId && u.Role == "manager")
            .ToListAsync();

        foreach (var user in users)
            await _email.SendDailyDigestAsync(user.Email, stats);
    }

    // Chạy mỗi 4h — nhắc nhở lead bị bỏ quên
    public async Task RemindStaleLeadsAsync()
    {
        var cutoff = DateTimeOffset.UtcNow.AddDays(-7);
        var staleLeads = await _context.Leads
            .Include(l => l.AssignedTo)
            .Where(l => l.Status == LeadStatus.Contacted
                     && l.UpdatedAt < cutoff
                     && l.AssignedTo != null)
            .ToListAsync();

        foreach (var lead in staleLeads)
            BackgroundJob.Enqueue(() =>
                _email.SendStaleLeadReminderAsync(lead.AssignedTo!.Email, lead.Id));
    }
}

// Đăng ký recurring jobs trong Program.cs
RecurringJob.AddOrUpdate<CrmJobs>(
    "daily-digest",
    job => job.SendDailyDigestAsync(Guid.Empty),  // tenantId lấy từ DB loop
    Cron.Daily(8, 0));

RecurringJob.AddOrUpdate<CrmJobs>(
    "stale-lead-reminder",
    job => job.RemindStaleLeadsAsync(),
    "0 */4 * * *");

6.3 Notification Center với SignalR ​

csharp
// Api/Hubs/NotificationHub.cs
[Authorize]
public class NotificationHub : Hub
{
    public override async Task OnConnectedAsync()
    {
        var userId = Context.UserIdentifier;
        var tenantId = Context.User?.FindFirst("tenant_id")?.Value;

        // Mỗi user join group của mình và group của tenant
        await Groups.AddToGroupAsync(Context.ConnectionId, $"user:{userId}");
        await Groups.AddToGroupAsync(Context.ConnectionId, $"tenant:{tenantId}");
        await base.OnConnectedAsync();
    }
}

// Infrastructure/Notifications/SignalRNotificationService.cs
public class SignalRNotificationService : INotificationService
{
    private readonly IHubContext<NotificationHub> _hub;
    private readonly CrmDbContext _context;

    public async Task SendAsync(SendNotificationRequest request)
    {
        // Persist vào DB
        var notification = new Notification
        {
            TenantId = request.TenantId,
            UserId = request.UserId,
            Type = request.Type,
            Title = request.Title,
            Body = request.Body
        };
        _context.Notifications.Add(notification);
        await _context.SaveChangesAsync();

        // Push real-time
        await _hub.Clients
            .Group($"user:{request.UserId}")
            .SendAsync("ReceiveNotification", new
            {
                notification.Id,
                notification.Title,
                notification.Body,
                notification.Type,
                notification.CreatedAt
            });
    }
}

6.4 Audit Log ​

csharp
// Infrastructure/Persistence/AuditInterceptor.cs
public class AuditInterceptor : SaveChangesInterceptor
{
    private readonly ITenantContext _tenant;
    private readonly ICurrentUser _currentUser;

    public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData, InterceptionResult<int> result, CancellationToken ct)
    {
        var context = eventData.Context!;
        var logs = new List<AuditLog>();

        foreach (var entry in context.ChangeTracker.Entries<TenantEntity>())
        {
            if (entry.State is not (EntityState.Added or EntityState.Modified or EntityState.Deleted))
                continue;

            logs.Add(new AuditLog
            {
                TenantId = _tenant.TenantId,
                UserId = _currentUser.UserId,
                EntityType = entry.Entity.GetType().Name,
                EntityId = entry.Entity.Id.ToString(),
                Action = entry.State.ToString(),
                OldValue = entry.State == EntityState.Modified
                    ? JsonSerializer.Serialize(entry.OriginalValues.ToObject())
                    : null,
                NewValue = entry.State != EntityState.Deleted
                    ? JsonSerializer.Serialize(entry.CurrentValues.ToObject())
                    : null,
                ChangedAt = DateTimeOffset.UtcNow
            });
        }

        context.Set<AuditLog>().AddRange(logs);
        return await base.SavingChangesAsync(eventData, result, ct);
    }
}

6.5 Dashboard API ​

csharp
// Application/Features/Dashboard/GetDashboardQuery.cs
public record GetDashboardQuery : IRequest<DashboardDto>;

public class GetDashboardHandler : IRequestHandler<GetDashboardQuery, DashboardDto>
{
    public async Task<DashboardDto> Handle(GetDashboardQuery _, CancellationToken ct)
    {
        // Cache per tenant, TTL 5 phút
        var cacheKey = $"dashboard:{_tenant.TenantId}";
        if (_cache.TryGetValue(cacheKey, out DashboardDto? cached))
            return cached!;

        var now = DateTimeOffset.UtcNow;
        var startOfMonth = new DateTimeOffset(now.Year, now.Month, 1, 0, 0, 0, TimeSpan.Zero);

        var result = new DashboardDto
        {
            TotalLeads = await _context.Leads.CountAsync(ct),
            NewLeadsThisMonth = await _context.Leads
                .CountAsync(l => l.CreatedAt >= startOfMonth, ct),
            TotalDealsValue = await _context.Deals
                .Where(d => d.ApprovalStatus == DealApprovalStatus.Approved)
                .SumAsync(d => d.Value ?? 0, ct),
            PendingApprovals = await _context.ApprovalRequests
                .CountAsync(a => a.Status == ApprovalStatus.Pending, ct),
            LeadsByStatus = await _context.Leads
                .GroupBy(l => l.Status)
                .Select(g => new StatusCountDto(g.Key.ToString(), g.Count()))
                .ToListAsync(ct)
        };

        _cache.Set(cacheKey, result, TimeSpan.FromMinutes(5));
        return result;
    }
}

6.6 Export CSV/Excel với Background Job ​

csharp
// Application/Features/Export/RequestExportCommand.cs
public record RequestExportCommand(string EntityType, ExportFormat Format) : IRequest<Guid>;

public class RequestExportHandler : IRequestHandler<RequestExportCommand, Guid>
{
    public async Task<Guid> Handle(RequestExportCommand cmd, CancellationToken ct)
    {
        var exportId = Guid.NewGuid();

        // Enqueue Hangfire job — tránh timeout HTTP
        BackgroundJob.Enqueue<ExportJob>(job =>
            job.GenerateAsync(exportId, _tenant.TenantId, cmd.EntityType, cmd.Format));

        return exportId;
    }
}

// Sau khi job xong, lưu file lên local/S3 và tạo presigned URL
public class ExportJob
{
    public async Task GenerateAsync(Guid exportId, Guid tenantId,
        string entityType, ExportFormat format)
    {
        // 1. Query dữ liệu
        // 2. Generate file (CsvHelper hoặc ClosedXML)
        // 3. Upload lên storage
        // 4. Notify user qua SignalR: "Export sẵn sàng, link download: ..."
        await _notificationService.SendAsync(new SendNotificationRequest
        {
            TenantId = tenantId,
            Type = "export_ready",
            Title = "File export đã sẵn sàng",
            Body = $"/api/exports/{exportId}/download"
        });
    }
}

7. Deployment Requirements ​

Docker Compose ​

yaml
# docker-compose.yml
version: '3.9'

networks:
  crm-net:
    driver: bridge

volumes:
  sqlserver-data:
  redis-data:
  hangfire-data:

services:
  sqlserver:
    image: mcr.microsoft.com/mssql/server:2022-latest
    environment:
      SA_PASSWORD: "${SQL_PASSWORD}"
      ACCEPT_EULA: "Y"
      MSSQL_PID: "Developer"
    ports:
      - "1433:1433"
    volumes:
      - sqlserver-data:/var/opt/mssql
    networks:
      - crm-net
    healthcheck:
      test: /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P "${SQL_PASSWORD}" -Q "SELECT 1"
      interval: 10s
      timeout: 5s
      retries: 10

  redis:
    image: redis:7-alpine
    command: redis-server --appendonly yes --requirepass "${REDIS_PASSWORD}"
    volumes:
      - redis-data:/data
    networks:
      - crm-net
    healthcheck:
      test: redis-cli -a "${REDIS_PASSWORD}" ping
      interval: 5s
      timeout: 3s
      retries: 5

  api:
    build:
      context: .
      dockerfile: src/CrmPlatform.Api/Dockerfile
    environment:
      ASPNETCORE_ENVIRONMENT: Production
      ConnectionStrings__DefaultConnection: "Server=sqlserver;Database=CrmDb;User Id=sa;Password=${SQL_PASSWORD};TrustServerCertificate=True"
      ConnectionStrings__Redis: "redis:6379,password=${REDIS_PASSWORD}"
      Jwt__Secret: "${JWT_SECRET}"
    ports:
      - "5000:8080"
    networks:
      - crm-net
    depends_on:
      sqlserver:
        condition: service_healthy
      redis:
        condition: service_healthy
    healthcheck:
      test: curl -f http://localhost:8080/health || exit 1
      interval: 15s
      timeout: 5s
      retries: 5

  nginx:
    image: nginx:alpine
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
    ports:
      - "80:80"
    networks:
      - crm-net
    depends_on:
      - api

Health Checks ​

csharp
// Program.cs
builder.Services.AddHealthChecks()
    .AddSqlServer(connectionString, name: "sqlserver", tags: ["db"])
    .AddRedis(redisConnection, name: "redis", tags: ["cache"])
    .AddHangfire(opt => opt.MinimumAvailableServers = 1, name: "hangfire", tags: ["jobs"]);

app.MapHealthChecks("/health", new HealthCheckOptions
{
    ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
    Predicate = check => check.Tags.Contains("db")
});

CI/CD Pipeline Spec (GitHub Actions) ​

yaml
# .github/workflows/ci.yml
name: CI/CD
on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      sqlserver:
        image: mcr.microsoft.com/mssql/server:2022-latest
        env:
          SA_PASSWORD: TestPassword123!
          ACCEPT_EULA: Y
        ports: ["1433:1433"]
      redis:
        image: redis:7-alpine
        ports: ["6379:6379"]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with: { dotnet-version: '8.x' }
      - run: dotnet restore
      - run: dotnet build --no-restore
      - run: dotnet test --no-build --verbosity normal

  build-push:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v5
        with:
          push: true
          tags: ghcr.io/${{ github.repository }}/api:latest

8. Performance Requirements ​

Mục tiêu ​

MetricTargetCách đo
API response p95< 200msk6 load test
DB query p95< 100msEF Core logging + slow query log
Redis cache hit rate> 80%Redis INFO stats
Concurrent users100k6 virtual users
Job throughput1000 jobs/phútHangfire metrics

Caching Strategy ​

csharp
// Infrastructure/Caching/CacheKeys.cs
public static class CacheKeys
{
    // Dashboard: cache 5 phút, invalidate khi có write
    public static string Dashboard(Guid tenantId) => $"dashboard:{tenantId}";

    // Lead list: cache 30 giây, invalidate khi thêm/sửa/xóa lead
    public static string LeadList(Guid tenantId, string hash) => $"leads:{tenantId}:{hash}";

    // User profile: cache 10 phút
    public static string UserProfile(Guid userId) => $"user:{userId}";
}

// Pattern: Cache-aside với IMemoryCache (L1) + Redis (L2)
public async Task<T?> GetOrSetAsync<T>(string key, Func<Task<T>> factory, TimeSpan ttl)
{
    // L1: memory cache
    if (_memCache.TryGetValue(key, out T? value)) return value;

    // L2: Redis
    var cached = await _redis.StringGetAsync(key);
    if (cached.HasValue)
    {
        value = JsonSerializer.Deserialize<T>(cached!);
        _memCache.Set(key, value, TimeSpan.FromSeconds(30)); // ngắn hơn Redis TTL
        return value;
    }

    // Miss: gọi factory
    value = await factory();
    var serialized = JsonSerializer.Serialize(value);
    await _redis.StringSetAsync(key, serialized, ttl);
    _memCache.Set(key, value, TimeSpan.FromSeconds(30));
    return value;
}

9. Testing Requirements ​

Integration Tests ​

csharp
// tests/CrmPlatform.IntegrationTests/LeadApprovalFlowTests.cs
public class LeadApprovalFlowTests : IClassFixture<CrmWebApplicationFactory>
{
    [Fact]
    public async Task CreateLead_QualifyLead_RequestApproval_ApproveDeal_ShouldUpdateStage()
    {
        // Arrange: tạo Lead
        var createResponse = await _client.PostAsJsonAsync("/api/leads", new
        {
            FullName = "Nguyễn Văn A",
            Email = "[email protected]"
        });
        var lead = await createResponse.Content.ReadFromJsonAsync<LeadDto>();

        // Act 1: qualify lead
        await _client.PatchAsync($"/api/leads/{lead!.Id}/qualify", null);

        // Act 2: tạo deal từ lead
        var dealResponse = await _client.PostAsJsonAsync("/api/deals", new
        {
            LeadId = lead.Id,
            Title = "Deal A",
            Value = 100_000_000
        });
        var deal = await dealResponse.Content.ReadFromJsonAsync<DealDto>();

        // Assert: deal cần approval vì value > threshold
        Assert.Equal("pending", deal!.ApprovalStatus);

        // Act 3: manager approve
        var approval = await _client.GetFromJsonAsync<ApprovalRequestDto>(
            $"/api/approvals?dealId={deal.Id}");
        await _managerClient.PostAsJsonAsync($"/api/approvals/{approval!.Id}/approve", new
        {
            Note = "OK, tiến hành"
        });

        // Assert: deal chuyển sang negotiation
        var updatedDeal = await _client.GetFromJsonAsync<DealDto>($"/api/deals/{deal.Id}");
        Assert.Equal("negotiation", updatedDeal!.Stage);
        Assert.Equal("approved", updatedDeal.ApprovalStatus);
    }
}

Load Test với k6 ​

javascript
// tests/CrmPlatform.LoadTests/load-test.js
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate } from 'k6/metrics';

const errorRate = new Rate('errors');

export const options = {
    stages: [
        { duration: '1m', target: 20 },   // ramp up
        { duration: '3m', target: 100 },  // steady state
        { duration: '1m', target: 0 },    // ramp down
    ],
    thresholds: {
        http_req_duration: ['p(95)<200'],  // p95 < 200ms
        errors: ['rate<0.01'],             // error rate < 1%
    },
};

export default function () {
    const res = http.get('http://localhost:5000/api/dashboard', {
        headers: { Authorization: `Bearer ${__ENV.JWT_TOKEN}` },
    });

    check(res, {
        'status 200': (r) => r.status === 200,
        'response time < 200ms': (r) => r.timings.duration < 200,
    });

    errorRate.add(res.status !== 200);
    sleep(1);
}

10. Bonus: .NET Aspire ​

Thay docker-compose.yml bằng .NET Aspire AppHost cho local development — IDE-friendly, observability built-in.

csharp
// CrmPlatform.AppHost/Program.cs
var builder = DistributedApplication.CreateBuilder(args);

var sqlServer = builder.AddSqlServer("sqlserver")
    .AddDatabase("CrmDb");

var redis = builder.AddRedis("redis");

var api = builder.AddProject<Projects.CrmPlatform_Api>("api")
    .WithReference(sqlServer)
    .WithReference(redis)
    .WithEnvironment("Hangfire__Storage", "sqlserver");

builder.Build().Run();

Lợi ích:

  • Dashboard tích hợp: traces, logs, metrics từ một URL
  • Service discovery tự động — không cần hardcode connection string
  • Hot reload friendly hơn Docker Compose
  • Dễ thêm Azure service (Service Bus, Blob Storage) khi deploy lên cloud

Cách chạy:

bash
cd CrmPlatform.AppHost
dotnet run
# Mở http://localhost:15000 — Aspire Dashboard

11. Checklist Nộp Bài ​

Infrastructure & Deployment (5 tiêu chí) ​

  • docker compose up chạy được toàn bộ stack không có lỗi
  • Health check endpoint /health trả về Healthy với đủ component
  • Migration tự động chạy khi startup ở Development
  • .env.example có đủ các biến môi trường cần thiết
  • Không có secret hardcode trong source code

Multi-tenant (4 tiêu chí) ​

  • TenantId có mặt trên mọi entity và được index
  • Global Query Filter hoạt động đúng: không thể query data của tenant khác
  • JWT claim tenant_id được validate ở mọi authenticated endpoint
  • Có integration test chứng minh tenant isolation

Database & EF Core (4 tiêu chí) ​

  • Migration clean, không có IgnoreQueryFilters ngoài ý muốn
  • Các index quan trọng đã được tạo (TenantId + Status, TenantId + CreatedAt)
  • Audit log ghi đầy đủ old value / new value cho mọi thay đổi entity
  • Seed data tạo được demo tenant + demo user

Approval Engine (3 tiêu chí) ​

  • Deal value > threshold phải tạo ApprovalRequest, không thể skip
  • Manager approve/reject → Deal stage cập nhật đúng
  • Notification được gửi đến người yêu cầu sau khi review

Background Jobs (3 tiêu chí) ​

  • Hangfire Dashboard accessible (có basic auth bảo vệ)
  • Ít nhất 2 recurring job đăng ký và chạy theo schedule
  • Job failed được retry tự động, sau N lần vào dead-letter queue

Notifications & Real-time (2 tiêu chí) ​

  • Notification persist vào DB, có API lấy danh sách + mark as read
  • SignalR push đúng đến user/tenant, không leak cross-tenant

Performance & Testing (4 tiêu chí) ​

  • Dashboard endpoint có cache, cache hit khi gọi lần 2 trong cùng TTL
  • Có ít nhất 5 integration test cho các luồng business quan trọng
  • k6 hoặc BenchmarkDotNet script tồn tại và có kết quả chạy được
  • Không có query N+1 ở các list endpoint (kiểm tra bằng EF Core logging)

11b. Kiểm tra & Thực hành (100 điểm) ​

Bối cảnh giáo trình (mục tiêu xuyên suốt) ​

Trục CRM — một mục tiêu rõ: bạn hướng tới CRM production trong From Zero → Senior .NET (Backend-first). Giáo trình ưu tiên backend, bám chuỗi API → dữ liệu → vận hành → kiến trúc phân tán, thay vì trải rộng theo hướng full-stack. Mỗi module củng cố một lớp kỹ năng trên cùng một CRM học tập bạn phát triển xuyên suốt khóa.

Chuỗi năng lựcĐóng góp vào CRM học tập
Giai đoạn 1 — FoundationĐọc hiểu yêu cầu, mô hình client–server, Git — nền collaboration khi CRM lớn dần.
Giai đoạn 2 — C# + P1Domain, OOP, LINQ, async, DI — rule nghiệp vụ & tầng ứng dụng (Inventory là bản mẫu trước CRM).
Giai đoạn 3 — ASP.NET + P2Host, routing, validation, JWT, SignalR — CRM Backend API thực tế.
Giai đoạn 4 — Data + P3SQL, EF, cache, job, container — CRM ổn định & có thể triển khai.
Giai đoạn 5 — Senior + FinalClean Architecture, event/outbox, gateway, observability, perf — CRM/ERP chịu tải & có chủ.

Vai trò giai đoạn này trong chuỗi ​

SQL, EF Core, cache, job nền, Docker + Project 3 — CRM chạy được như môi trường thật (dữ liệu, hiệu năng, vận hành).

Vị trí bài học (trước / sau) ​

MốcĐiểm nối trong giáo trình
TrướcModule 15 — Docker + Deployment
Tiếp theoModule 16 — Clean Architecture

Quy trình làm bài (hệ thống) ​

BướcViệc cần làmOutput nên có
1Làm Quiz trước, tự ghi đáp án + 1 dòng lý doKhông xem đáp án; giữ bản nháp
2Đối chiếu Đáp án; sửa hiểu sai, ghi takeaway vào README hoặc nhật ký học3–5 bullet “tôi đã hiểu…”
3Làm Lab theo rubric; trong README nêu phần CRM nào được chạm (Lead, Customer, Deal, Billing…)Repo / zip + ảnh dotnet run hoặc log
4Tự chấm theo bảng điểm; chuẩn bị 1 phút “vấn đáp” nếu mentor hỏi ngẫu nhiênĐiểm + chỗ còn yếu

Phần A — Quiz trắc nghiệm (20 điểm) ​

Gợi ý làm bài: mỗi câu 4 điểm. Liên tưởng CRM platform (P3): tenant isolation, job nền, cache, Docker — chuẩn bị cho Final và kiến trúc phân tán.

Mỗi câu 4 điểm.

  1. Global Query Filter TenantId + index phù hợp nhằm?
    A. Tăng độ dài JWT B. Giảm rủi ro lộ dữ liệu tenant khác + tối ưu truy vấn C. Thay connection pool D. Tắt migration

    Đáp án: B.

  2. Hangfire recurring job trên production cần?
    A. Chỉ RAM B. Storage persistence (SQL/Redis…) để schedule không mất khi restart C. Blazor D. IIS express

    Đáp án: B.

  3. IDistributedCache (Redis) trong platform này phục vụ?
    A. Thay EF hoàn toàn B. Cache/rate/token blacklist chia sẻ giữa instance C. Chỉ compile-time D. Chỉ Windows

    Đáp án: B.

  4. Readiness probe khác liveness?
    A. Giống nhau B. Ready kiểm tra DB/Redis/broker; Live chỉ process sống C. Chỉ cho gRPC D. Chỉ trên Mac

    Đáp án: B.

  5. docker-compose cho CRM platform giúp điều gì quan trọng nhất trong học tập?
    A. Xóa Git history B. Tái lập môi trường (API+DB+Redis) một lệnh, giảm “chạy được trên máy tôi” C. Thay unit test D. Ký APK Android

    Đáp án: B.

Phần B — Lab thực tế (80 điểm) ​

Lab & rubric: trong README, map từng nhóm điểm rubric sang phần CRM (Lead, Customer, Deal, Billing, Notification…) và artifact (test, log, screenshot, migration).

Map checklist mục 11: Docker/Secrets 12, Multi-tenant 12, DB & EF 12, Approval 9, Jobs 9, Notifications 6, Perf & Test 20 — tự tick và cộng; trừ nếu không có bằng chứng (log, test, ảnh).

Ngưỡng tổng điểm (Quiz 20 + Lab 80) ​

MứcĐiểmÝ nghĩa
Đạt≥ 70Đủ nền để học module kế mà không “lủng” kiến thức cốt lõi.
Khá≥ 85Có minh chứng code + nêu được liên hệ CRM rõ ràng trong README.
Giỏi≥ 95Có mở rộng / edge case / ADR ngắn / test bổ trợ (tùy rubric từng bài).

Phần C — Reflect & nối chuỗi (không chấm điểm) ​

Viết 5–8 câu (README hoặc docs/learning-log.md):

  1. Kiến thức module / project này sẽ được tái sử dụng trực tiếp ở module hoặc project CRM nào tiếp theo? (ghi tên module / P2 / P3 / Final).
  2. Nếu bỏ qua phần lab, rủi ro lớn nhất cho CRM ở giai đoạn sau là gì?
  3. Một quyết định kỹ thuật (nhỏ) bạn sẵn sàng ghi thành ADR một đoạn sau khi làm lab.

12. Cầu Nối Sang Stage 5 ​

Bạn vừa xây xong một CRM monolith production-ready. Stage 5 sẽ không viết lại từ đầu — mà refactor có chủ đích để giải quyết những vấn đề bạn tự nhận ra sau Project 3:

Vấn đề trong Project 3Giải pháp ở Stage 5
Use case handler trực tiếp gọi DbContext — khó testClean Architecture: Application layer chỉ dùng interface
Approval logic nằm trong handler — khó thay đổi ruleDomain-Driven Design: Deal aggregate với domain events
Notification gọi SignalR trực tiếp — tight couplingEvent-Driven: publish DealApproved event → handler gửi notification
Jobs biết quá nhiều về business logicCQRS: jobs chỉ dispatch command, handler xử lý
Khó tách Leads và Deals thành service riêngBounded Context: xác định ranh giới service trước khi tách

Ba bước chuyển đổi Stage 5 sẽ làm:

  1. Clean Architecture đúng nghĩa — tách domain entities khỏi EF Core entities, viết unit test thuần túy không cần DB.
  2. Domain Events + Outbox Pattern — thay direct call bằng event bus trong process, chuẩn bị cho message broker.
  3. Vertical Slice Architecture — nhóm code theo feature thay vì theo layer, dễ tách thành microservice sau này.

Bạn không cần refactor hoàn hảo ngay. Mục tiêu là hiểu tại sao kiến trúc cần thay đổi — và đó là điều Project 3 sẽ cho bạn thấy sau khi code và maintain nó một thời gian.

Mở rộng và đào sâu ​

Mục đích của phần này ​

Phần này giúp bạn nắm bản chất của chủ đề: học để giải quyết vấn đề gì, đặt ở đâu trong kiến trúc backend, và vì sao cách làm này quan trọng trong dự án thật.

Khung hiểu nhanh ​

  • Bài toán: vấn đề thực tế cần giải quyết.
  • Cách tiếp cận: kỹ thuật/chuẩn áp dụng trong bài.
  • Kết quả mong đợi: đầu ra đúng, dễ bảo trì, dễ mở rộng.

Mini case study ​

Tình huống ​

Chọn một use case nhỏ trong CRM/ERP liên quan trực tiếp đến bài học này, mô tả rõ đầu vào, đầu ra và tiêu chí thành công.

Đáp án gợi ý ​

  • Tách bài toán thành các bước xử lý rõ ràng.
  • Chọn điểm đặt logic đúng tầng (API, service, data, job...).
  • Bổ sung ít nhất một trường hợp biên để kiểm tra tính ổn định.

Ví dụ thực tế nhanh ​

  • Một ví dụ áp dụng trực tiếp vào luồng CRM/ERP hiện tại.
  • Một lỗi phổ biến dễ gặp khi triển khai thật.
  • Một cách kiểm tra nhanh để tự xác nhận bạn đã hiểu đúng.

Checklist trước khi sang bài tiếp theo ​

  • Bạn đã nắm được các khái niệm chính và giải thích lại được bằng ví dụ của riêng mình.
  • Bạn đã chạy hoặc mô phỏng ít nhất một ví dụ trong bài.
  • Bạn đã hoàn thành phần quiz/lab cơ bản (hoặc ít nhất tự làm lại theo trí nhớ).
  • Bạn đã ghi lại 3 ý chính: học được gì, còn vướng gì, sẽ áp dụng ở đâu trong CRM.

Phát hành theo giấy phép MIT.