Mentor Feature Implementation Guide
Mentor Feature Implementation Guide
Course: ITI Graduation Project - Career Route Platform
Feature: User Story 1 - Mentor Profile Management
Tasks Covered: T029, T036, T039, T044, T047, T053, T056, T059, T060
Architecture: 3-Layer Clean Architecture (API → Core → Infrastructure)
Table of Contents
- Architecture Overview
- Implementation Roadmap
- Task T029: Create Mentor Entity
- Task T036: Create Mentor DTOs
- Task T039: Configure Entity Mapping
- Task T044: Create Repository Interface
- Task T047: Implement Repository
- Task T053: Create and Implement Service
- Task T056: Create Validator
- Task T059: Implement Controller
- Task T060: Create AutoMapper Profile
- Testing Your Implementation
- Common Issues and Solutions
Architecture Overview
Understanding 3-Layer Clean Architecture
Our project follows a 3-layer clean architecture pattern, which separates concerns and makes the code maintainable and testable.
┌─────────────────────────────────────────────────────┐
│ CareerRoute.API (Presentation Layer) │
│ - Controllers (HTTP endpoints) │
│ - Middleware (logging, exception handling) │
│ - Filters (authorization) │
└──────────────────┬──────────────────────────────────┘
│ Depends on ↓
┌─────────────────────────────────────────────────────┐
│ CareerRoute.Core (Domain + Application) │
│ - Entities (domain models) │
│ - DTOs (data transfer objects) │
│ - Interfaces (contracts) │
│ - Services (business logic) │
│ - Validators (input validation) │
│ - Enums (constants) │
└──────────────────┬──────────────────────────────────┘
│ Depends on ↓
┌─────────────────────────────────────────────────────┐
│ CareerRoute.Infrastructure (Data Layer) │
│ - DbContext (database access) │
│ - Repositories (data operations) │
│ - Migrations (database schema) │
│ - External Services (email, storage, etc.) │
└─────────────────────────────────────────────────────┘
Key Principles:
- Dependency Flow: API → Core → Infrastructure (one direction only)
- Core is independent and contains business logic
- Infrastructure implements interfaces defined in Core
- API orchestrates everything through controllers
Implementation Roadmap
We'll implement the Mentor feature in this order:
graph TD
A[Step 1: Create Mentor Entity] --> B[Step 2: Create DTOs]
B --> C[Step 3: Configure EF Core Mapping]
C --> D[Step 4: Create Repository Interface]
D --> E[Step 5: Implement Repository]
E --> F[Step 6: Create Service Interface & Implementation]
F --> G[Step 7: Create Validator]
G --> H[Step 8: Create Controller]
H --> I[Step 9: Configure AutoMapper]
I --> J[Step 10: Test]
Why this order?
- Start with domain models (entities) - the foundation of your application
- Create DTOs to define data contracts for APIs
- Configure database mappings so EF Core knows how to persist entities
- Build repository to abstract data access
- Implement business logic in services
- Add validation to ensure data integrity
- Expose HTTP endpoints through controllers
- Wire everything with AutoMapper for object transformations
Task T029: Create Mentor Entity
What is an Entity?
An entity represents a real-world business object that has an identity and is stored in the database. In our case, a Mentor is a user who provides mentorship services.
Understanding the Mentor Entity
According to the data model:
- Mentor has a one-to-one relationship with User (a Mentor IS a User)
- Mentor.Id is a foreign key pointing to User.Id
- Contains mentor-specific fields: Bio, Expertise, Rates, Ratings, etc.
Implementation Steps
Location: Backend/CareerRoute.Core/Domain/Entities/Mentor.cs
Step 1: Create the Mentor Entity Class
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace CareerRoute.Core.Domain.Entities
{
/// <summary>
/// Represents a mentor in the system.
/// A mentor is a user who has been approved to provide mentorship services.
/// </summary>
public class Mentor
{
// ============ PRIMARY KEY ============
/// <summary>
/// Primary Key and Foreign Key to ApplicationUser.
/// This creates a one-to-one relationship: one User can be one Mentor.
/// </summary>
[Key]
[ForeignKey(nameof(User))]
public string Id { get; set; } = string.Empty;
// ============ NAVIGATION PROPERTY ============
/// <summary>
/// Navigation property to the related User entity.
/// This allows you to access mentor.User.Email, mentor.User.FirstName, etc.
/// </summary>
public virtual ApplicationUser User { get; set; } = null!;
// ============ PROFILE INFORMATION ============
/// <summary>
/// Mentor's professional biography.
/// This is displayed on their profile to attract mentees.
/// </summary>
[MaxLength(2000)]
public string? Bio { get; set; }
/// <summary>
/// Comma-separated list of expertise areas (e.g., "C#, ASP.NET, Azure").
/// Later we can enhance this to a separate ExpertiseTag entity.
/// </summary>
[MaxLength(500)]
public string? ExpertiseTags { get; set; }
/// <summary>
/// Number of years of professional experience.
/// Used for filtering and displaying credibility.
/// </summary>
public int? YearsOfExperience { get; set; }
/// <summary>
/// Mentor's certifications (e.g., "Microsoft Certified, AWS Solutions Architect").
/// Can be enhanced to a separate Certification entity.
/// </summary>
[MaxLength(1000)]
public string? Certifications { get; set; }
// ============ PRICING ============
/// <summary>
/// Hourly rate for 30-minute sessions in USD.
/// Example: 25.00 means $25 for 30 minutes.
/// </summary>
[Column(TypeName = "decimal(18,2)")]
public decimal Rate30Min { get; set; }
/// <summary>
/// Hourly rate for 60-minute sessions in USD.
/// Usually discounted compared to 2x the 30-min rate.
/// </summary>
[Column(TypeName = "decimal(18,2)")]
public decimal Rate60Min { get; set; }
// ============ RATING & STATISTICS ============
/// <summary>
/// Average rating calculated from all reviews (1-5 scale).
/// This is computed and updated when reviews are submitted.
/// </summary>
[Column(TypeName = "decimal(3,2)")]
public decimal AverageRating { get; set; } = 0;
/// <summary>
/// Total number of reviews received.
/// Used to display social proof (e.g., "4.8 stars from 127 reviews").
/// </summary>
public int TotalReviews { get; set; } = 0;
/// <summary>
/// Total number of sessions this mentor has completed.
/// Used for sorting by popularity and displaying experience.
/// </summary>
public int TotalSessionsCompleted { get; set; } = 0;
// ============ APPROVAL STATUS ============
/// <summary>
/// Indicates if the mentor has been verified by admins.
/// Verified mentors appear higher in search results.
/// </summary>
public bool IsVerified { get; set; } = false;
/// <summary>
/// Admin approval status: Pending, Approved, or Rejected.
/// New mentor applications start as Pending.
/// </summary>
[MaxLength(20)]
public string ApprovalStatus { get; set; } = "Pending";
// ============ TIMESTAMPS ============
/// <summary>
/// When the mentor profile was created (application submitted).
/// </summary>
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
/// <summary>
/// When the mentor profile was last updated.
/// </summary>
public DateTime? UpdatedAt { get; set; }
// ============ FUTURE RELATIONSHIPS ============
// These will be added in later tasks:
// - public virtual ICollection<Session> Sessions { get; set; }
// - public virtual ICollection<Review> Reviews { get; set; }
// - public virtual ICollection<MentorCategory> MentorCategories { get; set; }
}
}
Understanding the Code
1. Why string Id instead of int Id?
- ASP.NET Identity uses
stringfor user IDs (GUIDs) - Mentor.Id must match User.Id type for the foreign key relationship
2. What is [ForeignKey(nameof(User))]?
- This tells EF Core that
Idis a foreign key pointing to theUsernavigation property - Creates a one-to-one relationship: when you delete a User, the Mentor is also deleted (cascade)
3. Why virtual on navigation properties?
- Enables lazy loading: EF Core can load related entities on-demand
- Example:
var userName = mentor.User.FirstName;automatically loads the User if not loaded yet
4. Why required or = null!?
- C# 11+ nullable reference types require initialization
= null!tells the compiler "this will be set by EF Core, don't worry"
5. Why [MaxLength] attributes?
- Defines database column sizes
- Prevents users from submitting gigantic strings
- Database will create
nvarchar(2000)instead ofnvarchar(MAX)
6. Why decimal(18,2) for prices?
decimalis precise for money (unlikefloatwhich has rounding errors)18,2means 18 total digits, 2 after decimal point (e.g., 999999999999999.99)
7. Why DateTime.UtcNow instead of DateTime.Now?
- Always use UTC for server timestamps
- Prevents timezone confusion when users are in different countries
- Frontend can convert to local time for display
Summary Checklist
- <input type="checkbox" disabled=""> Create
Mentor.csinCareerRoute.Core/Domain/Entities/ - <input type="checkbox" disabled=""> Define all properties according to data model
- <input type="checkbox" disabled=""> Add XML comments for documentation
- <input type="checkbox" disabled=""> Use proper data annotations (
[Key],[MaxLength], etc.) - <input type="checkbox" disabled=""> Include navigation property to
ApplicationUser - <input type="checkbox" disabled=""> Set default values for rating fields
- <input type="checkbox" disabled=""> Use UTC timestamps
Task T036: Create Mentor DTOs
What are DTOs?
DTO = Data Transfer Object. DTOs are objects used to transfer data between layers (API ↔ Service ↔ Database).
Why not use Entities directly in API?
- Security: Entities may contain sensitive fields you don't want to expose
- Flexibility: API contracts can differ from database structure
- Versioning: You can change entities without breaking API contracts
- Performance: DTOs can be optimized for specific use cases (fewer fields)
Understanding DTOs for Mentor
We need two DTOs:
- MentorProfileDto: For reading mentor data (GET requests)
- UpdateMentorProfileDto: For updating mentor data (PUT requests)
Implementation Steps
Location: Backend/CareerRoute.Core/DTOs/Mentors/
Step 1: Create MentorProfileDto (Read DTO)
namespace CareerRoute.Core.DTOs.Mentors
{
/// <summary>
/// DTO for returning mentor profile information to clients.
/// Used in GET requests to display mentor details.
/// </summary>
public class MentorProfileDto
{
// ============ IDENTITY ============
/// <summary>
/// Mentor's unique identifier (matches User.Id).
/// </summary>
public string Id { get; set; } = string.Empty;
// ============ USER INFORMATION ============
// We include user info here so clients don't need a separate request
/// <summary>
/// Mentor's first name (from User entity).
/// </summary>
public string FirstName { get; set; } = string.Empty;
/// <summary>
/// Mentor's last name (from User entity).
/// </summary>
public string LastName { get; set; } = string.Empty;
/// <summary>
/// Full name computed on the fly.
/// </summary>
public string FullName => $"{FirstName} {LastName}";
/// <summary>
/// Mentor's email address (from User entity).
/// </summary>
public string Email { get; set; } = string.Empty;
/// <summary>
/// URL to mentor's profile picture (from User entity).
/// </summary>
public string? ProfilePictureUrl { get; set; }
// ============ MENTOR PROFILE ============
/// <summary>
/// Professional biography.
/// </summary>
public string? Bio { get; set; }
/// <summary>
/// List of expertise areas as an array.
/// Example: ["C#", "ASP.NET", "Azure"]
/// </summary>
public List<string> ExpertiseTags { get; set; } = new();
/// <summary>
/// Years of professional experience.
/// </summary>
public int? YearsOfExperience { get; set; }
/// <summary>
/// Professional certifications.
/// </summary>
public string? Certifications { get; set; }
// ============ PRICING ============
/// <summary>
/// Price for 30-minute session.
/// </summary>
public decimal Rate30Min { get; set; }
/// <summary>
/// Price for 60-minute session.
/// </summary>
public decimal Rate60Min { get; set; }
// ============ STATISTICS ============
/// <summary>
/// Average rating (1-5 scale).
/// </summary>
public decimal AverageRating { get; set; }
/// <summary>
/// Total number of reviews.
/// </summary>
public int TotalReviews { get; set; }
/// <summary>
/// Total completed sessions.
/// </summary>
public int TotalSessionsCompleted { get; set; }
/// <summary>
/// Whether mentor is verified by admins.
/// </summary>
public bool IsVerified { get; set; }
/// <summary>
/// Current approval status.
/// </summary>
public string ApprovalStatus { get; set; } = string.Empty;
/// <summary>
/// When the mentor profile was created.
/// </summary>
public DateTime CreatedAt { get; set; }
/// <summary>
/// When the mentor profile was last updated.
/// </summary>
public DateTime? UpdatedAt { get; set; }
}
}
Step 2: Create UpdateMentorProfileDto (Write DTO)
using System.ComponentModel.DataAnnotations;
namespace CareerRoute.Core.DTOs.Mentors
{
/// <summary>
/// DTO for updating an existing mentor profile.
/// Used in PUT requests when mentors edit their profiles.
/// Contains only fields that mentors are allowed to modify.
/// </summary>
public class UpdateMentorProfileDto
{
// ============ PROFILE INFORMATION ============
/// <summary>
/// Updated professional biography.
/// </summary>
[MaxLength(2000, ErrorMessage = "Bio cannot exceed 2000 characters")]
public string? Bio { get; set; }
/// <summary>
/// Updated expertise areas as a comma-separated string.
/// Example: "C#, ASP.NET, Azure"
/// </summary>
[MaxLength(500, ErrorMessage = "Expertise tags cannot exceed 500 characters")]
public string? ExpertiseTags { get; set; }
/// <summary>
/// Updated years of experience.
/// </summary>
[Range(0, 60, ErrorMessage = "Years of experience must be between 0 and 60")]
public int? YearsOfExperience { get; set; }
/// <summary>
/// Updated certifications.
/// </summary>
[MaxLength(1000, ErrorMessage = "Certifications cannot exceed 1000 characters")]
public string? Certifications { get; set; }
// ============ PRICING ============
/// <summary>
/// Updated price for 30-minute sessions.
/// </summary>
[Range(0.01, 10000, ErrorMessage = "30-minute rate must be between $0.01 and $10,000")]
public decimal Rate30Min { get; set; }
/// <summary>
/// Updated price for 60-minute sessions.
/// </summary>
[Range(0.01, 10000, ErrorMessage = "60-minute rate must be between $0.01 and $10,000")]
public decimal Rate60Min { get; set; }
// NOTE: We DON'T include these fields because they should NOT be editable by mentors:
// - AverageRating (calculated from reviews)
// - TotalReviews (calculated from reviews)
// - TotalSessionsCompleted (calculated from sessions)
// - IsVerified (only admins can change)
// - ApprovalStatus (only admins can change)
}
}
Understanding the Code
1. Why separate Read and Write DTOs?
- Read DTO includes everything clients need to display
- Write DTO includes only fields users can modify
- Example:
AverageRatingis in read DTO but NOT in write DTO (calculated field)
2. Why include User fields in MentorProfileDto?
- Convenience: Frontend gets all data in one request
- Avoids N+1 query problem (getting mentor, then user separately)
- Alternative: Create a separate
UserDtoand nest it
3. Why validation attributes on DTOs?
- First line of defense before data reaches the database
- ASP.NET Core automatically validates DTOs and returns
400 Bad Requestif invalid - You'll create custom validators in Task T056 for complex validation
4. Why List<string> for ExpertiseTags in read DTO but string? in write DTO?
- Read: Converts comma-separated string to array for frontend convenience
- Write: Accepts comma-separated string for simplicity (you'll split it in the service)
5. What about [Required] attribute?
- Not used here because all fields are optional when updating
- Mentors can update only Bio without changing pricing
Summary Checklist
- <input type="checkbox" disabled=""> Create
MentorProfileDto.csinCareerRoute.Core/DTOs/Mentors/ - <input type="checkbox" disabled=""> Create
UpdateMentorProfileDto.csin same folder - <input type="checkbox" disabled=""> Include User fields in MentorProfileDto for convenience
- <input type="checkbox" disabled=""> Add validation attributes to UpdateMentorProfileDto
- <input type="checkbox" disabled=""> Exclude computed/admin-only fields from UpdateMentorProfileDto
- <input type="checkbox" disabled=""> Add XML comments for documentation
Task T039: Configure Entity Mapping
What is Entity Framework Core Configuration?
EF Core uses conventions to map entities to database tables. However, sometimes we need explicit configuration:
- Define relationships (one-to-one, one-to-many)
- Set up indexes for performance
- Configure column types precisely
- Add constraints (unique, check constraints)
Why Separate Configuration Files?
Instead of putting all configuration in DbContext.OnModelCreating(), we use IEntityTypeConfiguration<T>:
- Separation of concerns: Each entity has its own configuration class
- Maintainability: Easier to find and update configuration
- Cleaner code: DbContext doesn't become a giant file
Implementation Steps
Location: Backend/CareerRoute.Infrastructure/Data/Configurations/MentorConfiguration.cs
Step 1: Create the Configurations Folder
First, ensure the folder exists:
Backend/CareerRoute.Infrastructure/Data/Configurations/
Step 2: Create MentorConfiguration Class
using CareerRoute.Core.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace CareerRoute.Infrastructure.Data.Configurations
{
/// <summary>
/// Entity Framework Core configuration for the Mentor entity.
/// Defines database schema, relationships, indexes, and constraints.
/// </summary>
public class MentorConfiguration : IEntityTypeConfiguration<Mentor>
{
public void Configure(EntityTypeBuilder<Mentor> builder)
{
// ============ TABLE CONFIGURATION ============
/// <summary>
/// Explicitly name the table "Mentors" in the database.
/// Convention would create "Mentor" (singular), but we prefer plural.
/// </summary>
builder.ToTable("Mentors");
// ============ PRIMARY KEY ============
/// <summary>
/// Define Id as the primary key.
/// This is also a foreign key to AspNetUsers.Id.
/// </summary>
builder.HasKey(m => m.Id);
// ============ ONE-TO-ONE RELATIONSHIP ============
/// <summary>
/// Configure one-to-one relationship between Mentor and ApplicationUser.
/// - One Mentor has one User
/// - One User can have zero or one Mentor (optional)
/// - If User is deleted, Mentor is also deleted (cascade)
/// </summary>
builder.HasOne(m => m.User)
.WithMany() // No navigation property from User to Mentor yet
.HasForeignKey(m => m.Id)
.OnDelete(DeleteBehavior.Cascade);
// ============ PROPERTY CONFIGURATION ============
/// <summary>
/// Configure Bio column.
/// </summary>
builder.Property(m => m.Bio)
.HasMaxLength(2000)
.IsRequired(false); // Optional field
/// <summary>
/// Configure ExpertiseTags column.
/// </summary>
builder.Property(m => m.ExpertiseTags)
.HasMaxLength(500)
.IsRequired(false);
/// <summary>
/// Configure YearsOfExperience.
/// Optional field (nullable int).
/// </summary>
builder.Property(m => m.YearsOfExperience)
.IsRequired(false);
/// <summary>
/// Configure Certifications column.
/// </summary>
builder.Property(m => m.Certifications)
.HasMaxLength(1000)
.IsRequired(false);
/// <summary>
/// Configure Rate30Min with precise decimal type.
/// SQL Server: decimal(18,2)
/// </summary>
builder.Property(m => m.Rate30Min)
.HasColumnType("decimal(18,2)")
.IsRequired();
/// <summary>
/// Configure Rate60Min with precise decimal type.
/// </summary>
builder.Property(m => m.Rate60Min)
.HasColumnType("decimal(18,2)")
.IsRequired();
/// <summary>
/// Configure AverageRating with decimal(3,2).
/// Range: 0.00 to 5.00
/// </summary>
builder.Property(m => m.AverageRating)
.HasColumnType("decimal(3,2)")
.HasDefaultValue(0)
.IsRequired();
/// <summary>
/// Configure TotalReviews with default value.
/// </summary>
builder.Property(m => m.TotalReviews)
.HasDefaultValue(0)
.IsRequired();
/// <summary>
/// Configure TotalSessionsCompleted with default value.
/// </summary>
builder.Property(m => m.TotalSessionsCompleted)
.HasDefaultValue(0)
.IsRequired();
/// <summary>
/// Configure IsVerified with default value.
/// New mentors are not verified by default.
/// </summary>
builder.Property(m => m.IsVerified)
.HasDefaultValue(false)
.IsRequired();
/// <summary>
/// Configure ApprovalStatus.
/// Default is "Pending" for new mentor applications.
/// </summary>
builder.Property(m => m.ApprovalStatus)
.HasMaxLength(20)
.HasDefaultValue("Pending")
.IsRequired();
/// <summary>
/// Configure CreatedAt timestamp.
/// Automatically set to current UTC time when record is created.
/// </summary>
builder.Property(m => m.CreatedAt)
.HasDefaultValueSql("GETUTCDATE()")
.IsRequired();
/// <summary>
/// Configure UpdatedAt timestamp.
/// Optional field, set when record is updated.
/// </summary>
builder.Property(m => m.UpdatedAt)
.IsRequired(false);
// ============ INDEXES FOR PERFORMANCE ============
/// <summary>
/// Composite index for mentor search and filtering.
/// Speeds up queries that filter by verification and availability.
/// Example: SELECT * FROM Mentors WHERE IsVerified = 1
/// </summary>
builder.HasIndex(m => new { m.IsVerified, m.ApprovalStatus })
.HasDatabaseName("IX_Mentor_IsVerified_ApprovalStatus");
/// <summary>
/// Index for sorting by average rating.
/// Speeds up queries like: ORDER BY AverageRating DESC
/// </summary>
builder.HasIndex(m => m.AverageRating)
.HasDatabaseName("IX_Mentor_AverageRating");
/// <summary>
/// Index for sorting by total sessions.
/// Speeds up "most popular" queries.
/// </summary>
builder.HasIndex(m => m.TotalSessionsCompleted)
.HasDatabaseName("IX_Mentor_TotalSessions");
// ============ CHECK CONSTRAINTS ============
// Note: Check constraints require EF Core 5.0+
// These ensure data integrity at the database level
/// <summary>
/// Ensure average rating is between 0 and 5.
/// </summary>
builder.ToTable(t => t.HasCheckConstraint(
"CK_Mentor_AverageRating",
"[AverageRating] >= 0 AND [AverageRating] <= 5"));
/// <summary>
/// Ensure 30-minute rate is positive.
/// </summary>
builder.ToTable(t => t.HasCheckConstraint(
"CK_Mentor_Rate30Min",
"[Rate30Min] > 0"));
/// <summary>
/// Ensure 60-minute rate is positive.
/// </summary>
builder.ToTable(t => t.HasCheckConstraint(
"CK_Mentor_Rate60Min",
"[Rate60Min] > 0"));
/// <summary>
/// Ensure years of experience is non-negative.
/// </summary>
builder.ToTable(t => t.HasCheckConstraint(
"CK_Mentor_YearsOfExperience",
"[YearsOfExperience] >= 0"));
}
}
}
Step 3: Register Configuration in DbContext
Update ApplicationDbContext.cs:
using CareerRoute.Core.Domain.Entities;
using CareerRoute.Infrastructure.Data.Configurations;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
namespace CareerRoute.Infrastructure.Data
{
public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
// ============ DBSETS ============
public DbSet<RefreshToken> RefreshTokens { get; set; }
/// <summary>
/// Add DbSet for Mentor entity.
/// This allows you to query: dbContext.Mentors.Where(...)
/// </summary>
public DbSet<Mentor> Mentors { get; set; }
// ============ MODEL CONFIGURATION ============
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
// Existing RefreshToken configuration
builder.Entity<RefreshToken>()
.HasOne(rt => rt.User)
.WithMany(u => u.RefreshTokens)
.HasForeignKey(rt => rt.UserId);
// ============ APPLY CONFIGURATIONS ============
// Automatically applies all IEntityTypeConfiguration classes in the assembly
builder.ApplyConfigurationsFromAssembly(typeof(ApplicationDbContext).Assembly);
// Alternative: Apply manually
// builder.ApplyConfiguration(new MentorConfiguration());
}
}
}
Understanding the Code
1. Why IEntityTypeConfiguration<Mentor>?
- Keeps configuration separate from DbContext
- Makes code modular and testable
- Follows Single Responsibility Principle
2. What is .ToTable("Mentors")?
- Sets the database table name
- Without this, EF Core would create a table named "Mentor" (singular)
3. Why define .HasOne() and .WithMany()?
- Explicitly configures the one-to-one relationship
.OnDelete(DeleteBehavior.Cascade)means: if User is deleted, Mentor is also deleted
4. What is HasMaxLength vs [MaxLength] attribute?
- Both do the same thing!
- Attribute: Applied on entity property
- Fluent API: Applied in configuration class
- Fluent API is more powerful (e.g., you can configure relationships)
5. Why decimal(18,2) for prices?
- Prevents floating-point precision errors
- Example:
9.99 + 0.01 = 10.00(exact), unlikefloatwhich might give10.000001
6. What are indexes?
- Speed up database queries
- Example:
IX_Mentor_AverageRatingspeeds upORDER BY AverageRating DESC - Trade-off: Indexes speed up reads but slow down writes slightly
7. What are check constraints?
- Database-level validation
- Example:
[AverageRating] >= 0 AND [AverageRating] <= 5prevents invalid data - Defense in depth: Even if C# validation fails, database rejects invalid data
8. Why GETUTCDATE() instead of DateTime.UtcNow?
GETUTCDATE(): Database server's UTC time (consistent)DateTime.UtcNow: Application server's UTC time (could differ in distributed systems)
Creating the Migration
After creating the configuration, you need to create a database migration:
# Navigate to Infrastructure project
cd Backend/CareerRoute.Infrastructure
# Create migration
dotnet ef migrations add AddMentorEntity --startup-project ../CareerRoute.API
# Apply migration to database
dotnet ef database update --startup-project ../CareerRoute.API
Summary Checklist
- <input type="checkbox" disabled=""> Create
MentorConfiguration.csinInfrastructure/Data/Configurations/ - <input type="checkbox" disabled=""> Configure table name, primary key, and relationships
- <input type="checkbox" disabled=""> Configure all properties with appropriate types and constraints
- <input type="checkbox" disabled=""> Add performance indexes
- <input type="checkbox" disabled=""> Add check constraints for data integrity
- <input type="checkbox" disabled=""> Register DbSet in ApplicationDbContext
- <input type="checkbox" disabled=""> Apply configurations using
ApplyConfigurationsFromAssembly - <input type="checkbox" disabled=""> Create and apply EF Core migration
Task T044: Create Repository Interface
What is the Repository Pattern?
The Repository Pattern abstracts data access logic:
- Separates business logic from data access
- Makes code testable (you can mock repositories)
- Centralizes data access logic
- Follows Dependency Inversion Principle (depend on interfaces, not implementations)
Repository Architecture
┌──────────────────────────────────────┐
│ Service Layer (Business Logic) │
│ Depends on IMentorRepository │
└────────────┬─────────────────────────┘
│ (Interface)
↓
┌──────────────────────────────────────┐
│ IMentorRepository │
│ (Contract: what operations exist) │
└────────────┬─────────────────────────┘
│ (Implementation)
↓
┌──────────────────────────────────────┐
│ MentorRepository │
│ (Actual database operations) │
└────────────┬─────────────────────────┘
│
↓
┌──────────────────────────────────────┐
│ ApplicationDbContext │
│ (Entity Framework) │
└──────────────────────────────────────┘
Implementation Steps
Location: Backend/CareerRoute.Core/Domain/Interfaces/IMentorRepository.cs
Step 1: Update IMentorRepository Interface
using CareerRoute.Core.Domain.Entities;
namespace CareerRoute.Core.Domain.Interfaces
{
/// <summary>
/// Repository interface for Mentor entity.
/// Defines data access operations for mentors.
/// Follows Repository Pattern to abstract database operations.
/// </summary>
public interface IMentorRepository : IBaseRepository<Mentor>
{
// ============ CUSTOM QUERIES ============
// These methods go beyond basic CRUD operations
/// <summary>
/// Get a mentor by their user ID, including related User entity.
/// Use this when you need mentor + user info in one query.
/// </summary>
/// <param name="userId">The ID of the user who is a mentor</param>
/// <returns>Mentor with User navigation property loaded, or null if not found</returns>
Task<Mentor?> GetMentorWithUserByIdAsync(string userId);
/// <summary>
/// Get all approved and verified mentors.
/// Used for public mentor listings and search.
/// </summary>
/// <returns>List of approved mentors</returns>
Task<IEnumerable<Mentor>> GetApprovedMentorsAsync();
/// <summary>
/// Get all mentors pending admin approval.
/// Used in admin dashboard to review new applications.
/// </summary>
/// <returns>List of pending mentors</returns>
Task<IEnumerable<Mentor>> GetPendingMentorsAsync();
/// <summary>
/// Search mentors by expertise tags or bio keywords.
/// Supports full-text search for better user experience.
/// </summary>
/// <param name="searchTerm">Keywords to search for</param>
/// <returns>List of matching mentors</returns>
Task<IEnumerable<Mentor>> SearchMentorsAsync(string searchTerm);
/// <summary>
/// Get top mentors by average rating.
/// Used for "Featured Mentors" or "Top Rated" sections.
/// </summary>
/// <param name="count">Number of top mentors to return</param>
/// <returns>List of top-rated mentors</returns>
Task<IEnumerable<Mentor>> GetTopRatedMentorsAsync(int count = 10);
/// <summary>
/// Check if a user is already a mentor.
/// Used to prevent duplicate mentor applications.
/// </summary>
/// <param name="userId">User ID to check</param>
/// <returns>True if user is a mentor, false otherwise</returns>
Task<bool> IsMentorAsync(string userId);
/// <summary>
/// Update mentor's average rating and review count.
/// Called after a new review is submitted.
/// </summary>
/// <param name="mentorId">Mentor ID</param>
/// <param name="newAverageRating">Updated average rating</param>
/// <param name="totalReviews">Updated total review count</param>
Task UpdateRatingAsync(string mentorId, decimal newAverageRating, int totalReviews);
/// <summary>
/// Increment the total sessions completed counter.
/// Called after a session is successfully completed.
/// </summary>
/// <param name="mentorId">Mentor ID</param>
Task IncrementSessionCountAsync(string mentorId);
}
}
Understanding the Code
1. Why extend IBaseRepository<Mentor>?
- Inherits basic CRUD operations:
GetAllAsync(),GetByIdAsync(),AddAsync(),Update(),Delete() - Avoids code duplication
- We only define custom mentor-specific operations
2. Why Task<Mentor?> instead of Task<Mentor>?
- The
?means nullable reference type (C# 11+) - Indicates the method may return null if mentor not found
- Helps prevent null reference exceptions
3. Why GetMentorWithUserByIdAsync?
- Eager loading: Loads mentor AND user in a single query
- Without this, you'd need 2 queries: get mentor, then get user (N+1 problem)
- Performance optimization
4. Why separate GetApprovedMentorsAsync and GetPendingMentorsAsync?
- Different use cases: public listing vs. admin dashboard
- Simplifies queries
- Better performance (filtered at database level)
5. Why UpdateRatingAsync instead of just using Update()?
- Encapsulates business logic: Rating calculation is complex
- Atomic operation: Updates multiple fields together
- Prevents errors: Ensures rating and review count stay in sync
6. What is Task<IEnumerable<Mentor>>?
Task: Asynchronous operation (doesn't block thread while waiting for database)IEnumerable<Mentor>: Collection of mentors (could be list, array, etc.)async/awaitpattern for better performance
Summary Checklist
- <input type="checkbox" disabled=""> Update
IMentorRepository.csto extendIBaseRepository<Mentor> - <input type="checkbox" disabled=""> Define custom query methods for mentor-specific operations
- <input type="checkbox" disabled=""> Use async/await pattern (
Task<T>) - <input type="checkbox" disabled=""> Use nullable types (
Mentor?) where appropriate - <input type="checkbox" disabled=""> Add XML comments for each method
- <input type="checkbox" disabled=""> Include methods for common use cases (search, top-rated, pending approval)
Task T047: Implement Repository
What Goes in a Repository Implementation?
The repository implementation:
- Uses Entity Framework Core to query the database
- Implements the interface methods
- Handles data access concerns (loading related entities, filtering, sorting)
- Does NOT contain business logic (that's in services)
Implementation Steps
Location: Backend/CareerRoute.Infrastructure/Repositories/MentorRepository.cs
Step 1: Implement MentorRepository
using CareerRoute.Core.Domain.Entities;
using CareerRoute.Core.Domain.Interfaces;
using CareerRoute.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace CareerRoute.Infrastructure.Repositories
{
/// <summary>
/// Implementation of IMentorRepository using Entity Framework Core.
/// Handles all database operations for Mentor entity.
/// </summary>
public class MentorRepository : GenericRepository<Mentor>, IMentorRepository
{
// Store DbContext for custom queries
private readonly ApplicationDbContext _context;
/// <summary>
/// Constructor: Inject ApplicationDbContext
/// </summary>
public MentorRepository(ApplicationDbContext context) : base(context)
{
_context = context;
}
// ============ CUSTOM QUERIES ============
/// <summary>
/// Get mentor with related User entity loaded.
/// Uses .Include() for eager loading.
/// </summary>
public async Task<Mentor?> GetMentorWithUserByIdAsync(string userId)
{
return await _context.Mentors
.Include(m => m.User) // Eager load User navigation property
.FirstOrDefaultAsync(m => m.Id == userId);
}
/// <summary>
/// Get all approved and verified mentors.
/// Includes User for displaying name/email.
/// </summary>
public async Task<IEnumerable<Mentor>> GetApprovedMentorsAsync()
{
return await _context.Mentors
.Include(m => m.User)
.Where(m => m.ApprovalStatus == "Approved" && m.IsVerified)
.OrderByDescending(m => m.AverageRating)
.ToListAsync();
}
/// <summary>
/// Get all mentors with pending approval status.
/// Used in admin dashboard.
/// </summary>
public async Task<IEnumerable<Mentor>> GetPendingMentorsAsync()
{
return await _context.Mentors
.Include(m => m.User)
.Where(m => m.ApprovalStatus == "Pending")
.OrderBy(m => m.CreatedAt) // Oldest first
.ToListAsync();
}
/// <summary>
/// Search mentors by keywords in ExpertiseTags or Bio.
/// Case-insensitive search.
/// </summary>
public async Task<IEnumerable<Mentor>> SearchMentorsAsync(string searchTerm)
{
if (string.IsNullOrWhiteSpace(searchTerm))
{
return await GetApprovedMentorsAsync();
}
var lowerSearchTerm = searchTerm.ToLower();
return await _context.Mentors
.Include(m => m.User)
.Where(m => m.ApprovalStatus == "Approved"
&& (m.ExpertiseTags!.ToLower().Contains(lowerSearchTerm)
|| m.Bio!.ToLower().Contains(lowerSearchTerm)
|| m.User.FirstName.ToLower().Contains(lowerSearchTerm)
|| m.User.LastName.ToLower().Contains(lowerSearchTerm)))
.OrderByDescending(m => m.AverageRating)
.ToListAsync();
}
/// <summary>
/// Get top-rated mentors.
/// Used for featured mentors section.
/// </summary>
public async Task<IEnumerable<Mentor>> GetTopRatedMentorsAsync(int count = 10)
{
return await _context.Mentors
.Include(m => m.User)
.Where(m => m.ApprovalStatus == "Approved"
&& m.IsVerified
&& m.TotalReviews > 0) // Only mentors with reviews
.OrderByDescending(m => m.AverageRating)
.ThenByDescending(m => m.TotalReviews) // Break ties with review count
.Take(count)
.ToListAsync();
}
/// <summary>
/// Check if a user is already a mentor.
/// </summary>
public async Task<bool> IsMentorAsync(string userId)
{
return await _context.Mentors
.AnyAsync(m => m.Id == userId);
}
/// <summary>
/// Update mentor's rating statistics.
/// This is called by the service after calculating new averages.
/// </summary>
public async Task UpdateRatingAsync(string mentorId, decimal newAverageRating, int totalReviews)
{
var mentor = await _context.Mentors.FindAsync(mentorId);
if (mentor == null)
{
throw new KeyNotFoundException($"Mentor with ID {mentorId} not found");
}
mentor.AverageRating = newAverageRating;
mentor.TotalReviews = totalReviews;
mentor.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
}
/// <summary>
/// Increment completed session counter.
/// Called after a session is marked as completed.
/// </summary>
public async Task IncrementSessionCountAsync(string mentorId)
{
var mentor = await _context.Mentors.FindAsync(mentorId);
if (mentor == null)
{
throw new KeyNotFoundException($"Mentor with ID {mentorId} not found");
}
mentor.TotalSessionsCompleted++;
mentor.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
}
}
}
Understanding the Code
1. Why extend GenericRepository<Mentor>?
- Inherits base CRUD operations (GetAllAsync, AddAsync, etc.)
- Avoids duplicating common code
- We only implement additional mentor-specific methods
2. What is .Include(m => m.User)?
- Eager loading: Loads related User entity in the same query
- Without it:
mentor.Userwould be null (lazy loading disabled by default) - Performance: One query instead of N+1 queries
3. Why FirstOrDefaultAsync vs SingleOrDefaultAsync?
FirstOrDefaultAsync: Returns first match or null (faster)SingleOrDefaultAsync: Throws exception if multiple matches (safety)- Use
Firstwhen you expect 0 or 1 result but don't want exception if >1
4. What is ToListAsync()?
- Executes the query and materializes results into a list
- Asynchronous: Doesn't block the thread
- Without it, query is not executed (deferred execution)
5. Why check string.IsNullOrWhiteSpace(searchTerm)?
- Defensive programming
- If search term is empty, return all approved mentors
- Prevents SQL errors from empty string queries
6. What is .ToLower().Contains()?
- Case-insensitive search
- Example: "C#" matches "c#", "C#", "C-Sharp"
- Limitation: Not efficient for large datasets (consider full-text search later)
7. Why .ThenByDescending(m => m.TotalReviews)?
- Secondary sort: If two mentors have same rating, sort by review count
- Example: 5.0 stars with 100 reviews ranks higher than 5.0 with 10 reviews
8. Why await _context.SaveChangesAsync() in some methods?
- Commits changes to the database
- Required after
.Update()or direct property changes - Not required after
.AddAsync()(you'll call SaveChanges in service layer)
9. Why throw KeyNotFoundException?
- Clearly indicates the problem
- Service layer can catch and return appropriate HTTP status (404 Not Found)
- Better than letting a null reference exception bubble up
Common Mistakes to Avoid
❌ Mistake 1: Forgetting .Include()
var mentor = await _context.Mentors.FirstOrDefaultAsync(m => m.Id == id);
// mentor.User is NULL! (unless lazy loading is enabled)
✅ Correct:
var mentor = await _context.Mentors
.Include(m => m.User)
.FirstOrDefaultAsync(m => m.Id == id);
// mentor.User is loaded
❌ Mistake 2: Forgetting .ToListAsync()
var mentors = _context.Mentors.Where(m => m.IsVerified);
// This is an IQueryable, not executed yet!
✅ Correct:
var mentors = await _context.Mentors
.Where(m => m.IsVerified)
.ToListAsync();
// Query executed, results in memory
❌ Mistake 3: Not using await
public Task<Mentor?> GetByIdAsync(string id)
{
return _context.Mentors.FindAsync(id); // ❌ Compiler error
}
✅ Correct:
public async Task<Mentor?> GetByIdAsync(string id)
{
return await _context.Mentors.FindAsync(id);
}
Summary Checklist
- <input type="checkbox" disabled=""> Create
MentorRepository.csinInfrastructure/Repositories/ - <input type="checkbox" disabled=""> Extend
GenericRepository<Mentor>and implementIMentorRepository - <input type="checkbox" disabled=""> Inject
ApplicationDbContextin constructor - <input type="checkbox" disabled=""> Use
.Include()for eager loading related entities - <input type="checkbox" disabled=""> Use
async/awaitpattern for all database operations - <input type="checkbox" disabled=""> Use
.ToListAsync(),.FirstOrDefaultAsync(), etc. for async queries - <input type="checkbox" disabled=""> Throw appropriate exceptions for error cases
- <input type="checkbox" disabled=""> Add defensive checks (null/empty parameters)
Task T053: Create and Implement Service
What is a Service Layer?
The Service Layer contains business logic:
- Orchestrates operations across multiple repositories
- Validates business rules
- Transforms entities to DTOs and vice versa
- Handles transactions
- Does NOT directly access DbContext (uses repositories)
Service vs Repository
| Repository | Service |
|---|---|
| Data access | Business logic |
| CRUD operations | Complex workflows |
| Returns entities | Returns DTOs |
| No validation | Validates business rules |
| Single entity focus | May use multiple repositories |
Implementation Steps
Location: Backend/CareerRoute.Core/Services/
Step 1: Create IMentorService Interface
File: Backend/CareerRoute.Core/Services/Interfaces/IMentorService.cs
using CareerRoute.Core.DTOs.Mentors;
namespace CareerRoute.Core.Services.Interfaces
{
/// <summary>
/// Service interface for mentor-related business operations.
/// Defines high-level operations that combine repository calls with business logic.
/// </summary>
public interface IMentorService
{
// ============ MENTOR PROFILE OPERATIONS ============
/// <summary>
/// Get a mentor's complete profile by ID.
/// Returns DTO suitable for public display.
/// </summary>
/// <param name="mentorId">Mentor's unique ID</param>
/// <returns>Mentor profile DTO or null if not found</returns>
Task<MentorProfileDto?> GetMentorProfileAsync(string mentorId);
/// <summary>
/// Get all approved mentors (for public listing).
/// Returns DTOs with basic info for search results.
/// </summary>
/// <returns>List of mentor profile DTOs</returns>
Task<IEnumerable<MentorProfileDto>> GetAllApprovedMentorsAsync();
/// <summary>
/// Update a mentor's profile.
/// Only the mentor themselves can update their profile.
/// </summary>
/// <param name="mentorId">Mentor ID</param>
/// <param name="updateDto">Updated profile data</param>
/// <returns>Updated mentor profile DTO</returns>
Task<MentorProfileDto> UpdateMentorProfileAsync(string mentorId, UpdateMentorProfileDto updateDto);
/// <summary>
/// Create a new mentor profile (user applying to become a mentor).
/// Creates a Mentor record linked to the User.
/// </summary>
/// <param name="userId">User ID of the applicant</param>
/// <param name="mentorData">Initial mentor profile data</param>
/// <returns>Created mentor profile DTO</returns>
Task<MentorProfileDto> CreateMentorProfileAsync(string userId, UpdateMentorProfileDto mentorData);
// ============ SEARCH & FILTERING ============
/// <summary>
/// Search mentors by keywords.
/// Searches in bio, expertise tags, and name.
/// </summary>
/// <param name="searchTerm">Search keywords</param>
/// <returns>List of matching mentor profiles</returns>
Task<IEnumerable<MentorProfileDto>> SearchMentorsAsync(string searchTerm);
/// <summary>
/// Get top-rated mentors.
/// Used for featured sections.
/// </summary>
/// <param name="count">Number of mentors to return</param>
/// <returns>List of top-rated mentor profiles</returns>
Task<IEnumerable<MentorProfileDto>> GetTopRatedMentorsAsync(int count = 10);
// ============ ADMIN OPERATIONS ============
/// <summary>
/// Get all mentors pending admin approval.
/// Admin-only operation.
/// </summary>
/// <returns>List of pending mentor profiles</returns>
Task<IEnumerable<MentorProfileDto>> GetPendingMentorApplicationsAsync();
/// <summary>
/// Approve a mentor application.
/// Admin-only operation.
/// </summary>
/// <param name="mentorId">Mentor ID to approve</param>
Task ApproveMentorAsync(string mentorId);
/// <summary>
/// Reject a mentor application.
/// Admin-only operation.
/// </summary>
/// <param name="mentorId">Mentor ID to reject</param>
/// <param name="reason">Rejection reason (sent to applicant)</param>
Task RejectMentorAsync(string mentorId, string reason);
// ============ VALIDATION ============
/// <summary>
/// Check if a user is already a mentor.
/// Prevents duplicate applications.
/// </summary>
/// <param name="userId">User ID to check</param>
/// <returns>True if user is a mentor</returns>
Task<bool> IsMentorAsync(string userId);
}
}
Step 2: Implement MentorService
File: Backend/CareerRoute.Core/Services/Implementations/MentorService.cs
using AutoMapper;
using CareerRoute.Core.Domain.Entities;
using CareerRoute.Core.Domain.Interfaces;
using CareerRoute.Core.DTOs.Mentors;
using CareerRoute.Core.Exceptions;
using CareerRoute.Core.Services.Interfaces;
using Microsoft.Extensions.Logging;
namespace CareerRoute.Core.Services.Implementations
{
/// <summary>
/// Implementation of IMentorService.
/// Contains business logic for mentor operations.
/// </summary>
public class MentorService : IMentorService
{
private readonly IMentorRepository _mentorRepository;
private readonly IUserRepository _userRepository;
private readonly IMapper _mapper;
private readonly ILogger<MentorService> _logger;
/// <summary>
/// Constructor: Inject dependencies
/// </summary>
public MentorService(
IMentorRepository mentorRepository,
IUserRepository userRepository,
IMapper mapper,
ILogger<MentorService> logger)
{
_mentorRepository = mentorRepository;
_userRepository = userRepository;
_mapper = mapper;
_logger = logger;
}
// ============ MENTOR PROFILE OPERATIONS ============
/// <summary>
/// Get mentor profile by ID.
/// </summary>
public async Task<MentorProfileDto?> GetMentorProfileAsync(string mentorId)
{
_logger.LogInformation("Fetching mentor profile for ID: {MentorId}", mentorId);
var mentor = await _mentorRepository.GetMentorWithUserByIdAsync(mentorId);
if (mentor == null)
{
_logger.LogWarning("Mentor with ID {MentorId} not found", mentorId);
return null;
}
return _mapper.Map<MentorProfileDto>(mentor);
}
/// <summary>
/// Get all approved mentors.
/// </summary>
public async Task<IEnumerable<MentorProfileDto>> GetAllApprovedMentorsAsync()
{
_logger.LogInformation("Fetching all approved mentors");
var mentors = await _mentorRepository.GetApprovedMentorsAsync();
return _mapper.Map<IEnumerable<MentorProfileDto>>(mentors);
}
/// <summary>
/// Update mentor profile.
/// Business rules:
/// - Mentor must exist
/// - 60-min rate should be >= 30-min rate (but we allow flexibility)
/// </summary>
public async Task<MentorProfileDto> UpdateMentorProfileAsync(
string mentorId,
UpdateMentorProfileDto updateDto)
{
_logger.LogInformation("Updating mentor profile for ID: {MentorId}", mentorId);
// Fetch existing mentor
var mentor = await _mentorRepository.GetMentorWithUserByIdAsync(mentorId);
if (mentor == null)
{
_logger.LogError("Mentor with ID {MentorId} not found", mentorId);
throw new NotFoundException($"Mentor with ID {mentorId} not found");
}
// Business validation: 60-min rate should be reasonable
if (updateDto.Rate60Min < updateDto.Rate30Min)
{
_logger.LogWarning("60-min rate is less than 30-min rate for mentor {MentorId}", mentorId);
// We log but don't block - mentor might offer bulk discount
}
// Update properties
mentor.Bio = updateDto.Bio;
mentor.ExpertiseTags = updateDto.ExpertiseTags;
mentor.YearsOfExperience = updateDto.YearsOfExperience;
mentor.Certifications = updateDto.Certifications;
mentor.Rate30Min = updateDto.Rate30Min;
mentor.Rate60Min = updateDto.Rate60Min;
mentor.UpdatedAt = DateTime.UtcNow;
// Save changes
_mentorRepository.Update(mentor);
await _mentorRepository.SaveChangesAsync();
_logger.LogInformation("Mentor profile updated successfully for ID: {MentorId}", mentorId);
// Return updated DTO
return _mapper.Map<MentorProfileDto>(mentor);
}
/// <summary>
/// Create a new mentor profile.
/// Business rules:
/// - User must exist
/// - User must not already be a mentor
/// - New mentors start with "Pending" approval status
/// </summary>
public async Task<MentorProfileDto> CreateMentorProfileAsync(
string userId,
UpdateMentorProfileDto mentorData)
{
_logger.LogInformation("Creating mentor profile for user ID: {UserId}", userId);
// Validate user exists
var user = await _userRepository.GetByIdAsync(userId);
if (user == null)
{
_logger.LogError("User with ID {UserId} not found", userId);
throw new NotFoundException($"User with ID {userId} not found");
}
// Check if user is already a mentor
if (await _mentorRepository.IsMentorAsync(userId))
{
_logger.LogWarning("User {UserId} is already a mentor", userId);
throw new BadRequestException("User is already a mentor");
}
// Create new mentor entity
var mentor = new Mentor
{
Id = userId, // Same as User ID (one-to-one)
Bio = mentorData.Bio,
ExpertiseTags = mentorData.ExpertiseTags,
YearsOfExperience = mentorData.YearsOfExperience,
Certifications = mentorData.Certifications,
Rate30Min = mentorData.Rate30Min,
Rate60Min = mentorData.Rate60Min,
ApprovalStatus = "Pending", // Requires admin approval
IsVerified = false,
CreatedAt = DateTime.UtcNow
};
// Save to database
await _mentorRepository.AddAsync(mentor);
await _mentorRepository.SaveChangesAsync();
_logger.LogInformation("Mentor profile created successfully for user ID: {UserId}", userId);
// Reload with user data for DTO mapping
var createdMentor = await _mentorRepository.GetMentorWithUserByIdAsync(userId);
return _mapper.Map<MentorProfileDto>(createdMentor!);
}
// ============ SEARCH & FILTERING ============
/// <summary>
/// Search mentors by keywords.
/// </summary>
public async Task<IEnumerable<MentorProfileDto>> SearchMentorsAsync(string searchTerm)
{
_logger.LogInformation("Searching mentors with term: {SearchTerm}", searchTerm);
var mentors = await _mentorRepository.SearchMentorsAsync(searchTerm);
return _mapper.Map<IEnumerable<MentorProfileDto>>(mentors);
}
/// <summary>
/// Get top-rated mentors.
/// </summary>
public async Task<IEnumerable<MentorProfileDto>> GetTopRatedMentorsAsync(int count = 10)
{
_logger.LogInformation("Fetching top {Count} rated mentors", count);
var mentors = await _mentorRepository.GetTopRatedMentorsAsync(count);
return _mapper.Map<IEnumerable<MentorProfileDto>>(mentors);
}
// ============ ADMIN OPERATIONS ============
/// <summary>
/// Get pending mentor applications.
/// </summary>
public async Task<IEnumerable<MentorProfileDto>> GetPendingMentorApplicationsAsync()
{
_logger.LogInformation("Fetching pending mentor applications");
var mentors = await _mentorRepository.GetPendingMentorsAsync();
return _mapper.Map<IEnumerable<MentorProfileDto>>(mentors);
}
/// <summary>
/// Approve a mentor application.
/// Business rule: Changes status to "Approved" and sets IsVerified = true
/// </summary>
public async Task ApproveMentorAsync(string mentorId)
{
_logger.LogInformation("Approving mentor ID: {MentorId}", mentorId);
var mentor = await _mentorRepository.GetMentorWithUserByIdAsync(mentorId);
if (mentor == null)
{
_logger.LogError("Mentor with ID {MentorId} not found", mentorId);
throw new NotFoundException($"Mentor with ID {mentorId} not found");
}
mentor.ApprovalStatus = "Approved";
mentor.IsVerified = true;
mentor.UpdatedAt = DateTime.UtcNow;
_mentorRepository.Update(mentor);
await _mentorRepository.SaveChangesAsync();
_logger.LogInformation("Mentor {MentorId} approved successfully", mentorId);
// TODO: Send approval email to mentor (implement in EmailService)
}
/// <summary>
/// Reject a mentor application.
/// </summary>
public async Task RejectMentorAsync(string mentorId, string reason)
{
_logger.LogInformation("Rejecting mentor ID: {MentorId}, Reason: {Reason}", mentorId, reason);
var mentor = await _mentorRepository.GetMentorWithUserByIdAsync(mentorId);
if (mentor == null)
{
_logger.LogError("Mentor with ID {MentorId} not found", mentorId);
throw new NotFoundException($"Mentor with ID {mentorId} not found");
}
mentor.ApprovalStatus = "Rejected";
mentor.UpdatedAt = DateTime.UtcNow;
_mentorRepository.Update(mentor);
await _mentorRepository.SaveChangesAsync();
_logger.LogInformation("Mentor {MentorId} rejected successfully", mentorId);
// TODO: Send rejection email with reason (implement in EmailService)
}
// ============ VALIDATION ============
/// <summary>
/// Check if user is a mentor.
/// </summary>
public async Task<bool> IsMentorAsync(string userId)
{
return await _mentorRepository.IsMentorAsync(userId);
}
}
}
Understanding the Code
1. Why inject multiple dependencies?
IMentorRepository: For mentor data accessIUserRepository: To validate user exists when creating mentorIMapper: To convert entities to DTOsILogger: For logging (debugging, monitoring)
2. What is IMapper?
- Part of AutoMapper library
- Automatically converts
Mentor→MentorProfileDto - Eliminates boilerplate mapping code
- You'll configure mappings in Task T060
3. Why throw custom exceptions?
NotFoundException: Returns 404 HTTP statusBadRequestException: Returns 400 HTTP status- Better than generic
Exception(clearer intent) - You'll create these in
CareerRoute.Core/Exceptions/
4. Why log at different levels?
LogInformation: Normal operations (audit trail)LogWarning: Unusual but not error (e.g., duplicate mentor application)LogError: Actual errors (entity not found)- Production debugging: Check logs to troubleshoot issues
5. Why reload entity after creation?
var createdMentor = await _mentorRepository.GetMentorWithUserByIdAsync(userId);
- After
AddAsync, navigation propertyUseris not loaded .Include(m => m.User)loads it- Needed for DTO mapping (MentorProfileDto includes user info)
6. Why check Rate60Min < Rate30Min?
- Business validation: Usually 60-min should cost more (or at least equal)
- We log but don't block (mentor might offer bulk discount)
- Your choice: Strict (throw exception) or lenient (just warn)
7. Why UpdatedAt = DateTime.UtcNow?
- Tracks when record was last modified
- Useful for audit trails and optimistic concurrency
Step 3: Create Custom Exceptions
File: Backend/CareerRoute.Core/Exceptions/NotFoundException.cs
namespace CareerRoute.Core.Exceptions
{
/// <summary>
/// Exception thrown when a requested entity is not found.
/// Maps to HTTP 404 Not Found.
/// </summary>
public class NotFoundException : Exception
{
public NotFoundException(string message) : base(message)
{
}
public NotFoundException(string message, Exception innerException)
: base(message, innerException)
{
}
}
}
File: Backend/CareerRoute.Core/Exceptions/BadRequestException.cs
namespace CareerRoute.Core.Exceptions
{
/// <summary>
/// Exception thrown when request data is invalid.
/// Maps to HTTP 400 Bad Request.
/// </summary>
public class BadRequestException : Exception
{
public BadRequestException(string message) : base(message)
{
}
public BadRequestException(string message, Exception innerException)
: base(message, innerException)
{
}
}
}
Summary Checklist
- <input type="checkbox" disabled=""> Create
IMentorService.csinterface inCore/Services/Interfaces/ - <input type="checkbox" disabled=""> Create
MentorService.csimplementation inCore/Services/Implementations/ - <input type="checkbox" disabled=""> Inject dependencies (repository, mapper, logger)
- <input type="checkbox" disabled=""> Implement all interface methods
- <input type="checkbox" disabled=""> Add business validation logic
- <input type="checkbox" disabled=""> Use logging for debugging and audit
- <input type="checkbox" disabled=""> Create custom exception classes (
NotFoundException,BadRequestException) - <input type="checkbox" disabled=""> Return DTOs, not entities
Task T056: Create Validator
What is FluentValidation?
FluentValidation is a popular .NET library for building strongly-typed validation rules:
- Fluent syntax: Chainable, readable validation rules
- Reusable: Validators are classes that can be tested
- Integrated with ASP.NET Core: Automatic validation before controller actions
- Custom rules: You can create complex validation logic
Why Validate in Multiple Layers?
| Layer | Validation Type | Example |
|---|---|---|
| DTO | Basic constraints | [MaxLength(2000)] |
| Validator | Complex business rules | "60-min rate must be > 30-min rate" |
| Service | Cross-entity validation | "User must exist before creating mentor" |
| Database | Data integrity | Check constraint: [AverageRating] <= 5 |
Defense in depth: Multiple layers catch different types of errors.
Implementation Steps
Location: Backend/CareerRoute.Core/Validators/
Step 1: Install FluentValidation
cd Backend/CareerRoute.Core
dotnet add package FluentValidation
dotnet add package FluentValidation.AspNetCore
Step 2: Create UpdateMentorProfileValidator
File: Backend/CareerRoute.Core/Validators/UpdateMentorProfileValidator.cs
using CareerRoute.Core.DTOs.Mentors;
using FluentValidation;
namespace CareerRoute.Core.Validators
{
/// <summary>
/// Validator for UpdateMentorProfileDto.
/// Defines business rules for mentor profile updates.
/// </summary>
public class UpdateMentorProfileValidator : AbstractValidator<UpdateMentorProfileDto>
{
public UpdateMentorProfileValidator()
{
// ============ BIO VALIDATION ============
RuleFor(x => x.Bio)
.MaximumLength(2000)
.WithMessage("Bio cannot exceed 2000 characters")
.MinimumLength(50)
.WithMessage("Bio must be at least 50 characters to provide meaningful information")
.When(x => !string.IsNullOrWhiteSpace(x.Bio)); // Only validate if provided
// ============ EXPERTISE TAGS VALIDATION ============
RuleFor(x => x.ExpertiseTags)
.MaximumLength(500)
.WithMessage("Expertise tags cannot exceed 500 characters")
.Must(BeValidTagsFormat)
.WithMessage("Expertise tags must be comma-separated (e.g., 'C#, ASP.NET, Azure')")
.When(x => !string.IsNullOrWhiteSpace(x.ExpertiseTags));
// ============ YEARS OF EXPERIENCE VALIDATION ============
RuleFor(x => x.YearsOfExperience)
.GreaterThanOrEqualTo(0)
.WithMessage("Years of experience cannot be negative")
.LessThanOrEqualTo(60)
.WithMessage("Years of experience cannot exceed 60 years")
.When(x => x.YearsOfExperience.HasValue);
// ============ CERTIFICATIONS VALIDATION ============
RuleFor(x => x.Certifications)
.MaximumLength(1000)
.WithMessage("Certifications cannot exceed 1000 characters")
.When(x => !string.IsNullOrWhiteSpace(x.Certifications));
// ============ PRICING VALIDATION ============
RuleFor(x => x.Rate30Min)
.GreaterThan(0)
.WithMessage("30-minute rate must be greater than $0")
.LessThanOrEqualTo(10000)
.WithMessage("30-minute rate cannot exceed $10,000");
RuleFor(x => x.Rate60Min)
.GreaterThan(0)
.WithMessage("60-minute rate must be greater than $0")
.LessThanOrEqualTo(10000)
.WithMessage("60-minute rate cannot exceed $10,000");
// ============ CROSS-PROPERTY VALIDATION ============
RuleFor(x => x.Rate60Min)
.GreaterThanOrEqualTo(x => x.Rate30Min)
.WithMessage("60-minute rate should be at least equal to 30-minute rate")
.WithSeverity(Severity.Warning); // Warning, not error
// ============ CUSTOM VALIDATION ============
RuleFor(x => x)
.Must(HaveReasonablePricing)
.WithMessage("Pricing seems unrealistic. Please verify your rates.")
.When(x => x.Rate30Min > 0 && x.Rate60Min > 0);
}
// ============ CUSTOM VALIDATION METHODS ============
/// <summary>
/// Validate that expertise tags are comma-separated.
/// </summary>
private bool BeValidTagsFormat(string? tags)
{
if (string.IsNullOrWhiteSpace(tags))
return true;
// Check if tags contain valid characters (letters, numbers, commas, spaces)
return tags.All(c => char.IsLetterOrDigit(c) || c == ',' || c == ' ' || c == '#' || c == '+' || c == '-');
}
/// <summary>
/// Business rule: 60-min rate should not be more than 2.5x the 30-min rate.
/// Example: If 30-min = $50, 60-min should be <= $125 (not $300).
/// </summary>
private bool HaveReasonablePricing(UpdateMentorProfileDto dto)
{
if (dto.Rate30Min <= 0 || dto.Rate60Min <= 0)
return true; // Skip if rates not set
var maxReasonableRate = dto.Rate30Min * 2.5m;
return dto.Rate60Min <= maxReasonableRate;
}
}
}
Step 3: Register Validators in DependencyInjection
Update: Backend/CareerRoute.Core/DependencyInjection.cs
using CareerRoute.Core.Services.Implementations;
using CareerRoute.Core.Services.Interfaces;
using CareerRoute.Core.Validators;
using FluentValidation;
using FluentValidation.AspNetCore;
using Microsoft.Extensions.DependencyInjection;
namespace CareerRoute.Core
{
public static class DependencyInjection
{
public static IServiceCollection AddCoreServices(this IServiceCollection services)
{
// ============ SERVICES ============
services.AddScoped<IMentorService, MentorService>();
// Add other services here...
// ============ AUTOMAPPER ============
services.AddAutoMapper(typeof(DependencyInjection).Assembly);
// ============ FLUENTVALIDATION ============
services.AddValidatorsFromAssemblyContaining<UpdateMentorProfileValidator>();
services.AddFluentValidationAutoValidation();
services.AddFluentValidationClientsideAdapters();
return services;
}
}
}
Understanding the Code
1. What is AbstractValidator<T>?
- Base class for FluentValidation validators
- Generic type:
<UpdateMentorProfileDto>means we're validating this DTO - Provides fluent API:
RuleFor(),Must(),WithMessage(), etc.
2. What is RuleFor(x => x.Bio)?
- Defines a validation rule for the
Bioproperty - Lambda expression:
x =>refers to the DTO being validated
3. What is .MaximumLength(2000)?
- Built-in validator: Checks string length
- Chainable: You can add multiple validators
4. What is .WithMessage()?
- Custom error message returned if validation fails
- Overrides default message ("Bio must not exceed 2000 characters")
5. What is .When()?
- Conditional validation: Only validates if condition is true
- Example:
.When(x => !string.IsNullOrWhiteSpace(x.Bio))→ Only validate Bio if it's not empty
6. What is .WithSeverity(Severity.Warning)?
- Marks rule as a warning instead of error
- Warning: Validation passes, but message is included
- Error: Validation fails, request is rejected
7. What is .Must(HaveReasonablePricing)?
- Custom validation method
- Takes a predicate function that returns
true(valid) orfalse(invalid) - Useful for complex business rules
8. Why validate tags format?
- Prevents injection attacks:
<script>alert('XSS')</script> - Ensures consistent format for parsing
- Example valid:
C#, ASP.NET, Azure - Example invalid:
C#; DROP TABLE Users;--
9. Why check "reasonable pricing"?
- User experience: Catches typos (e.g., $5000 instead of $50)
- Data quality: Prevents obviously wrong data
- Not a hard rule (just a warning)
10. How does ASP.NET Core use these validators?
- Automatically validates DTOs before controller actions
- If invalid: Returns
400 Bad Requestwith error messages - If valid: Controller action executes
Example Validation Output
Invalid Request:
POST /api/mentors/profile
{
"bio": "Hi",
"rate30Min": -10,
"rate60Min": 5000
}
Response:
HTTP 400 Bad Request
{
"errors": {
"Bio": ["Bio must be at least 50 characters to provide meaningful information"],
"Rate30Min": ["30-minute rate must be greater than $0"],
"Rate60Min": ["60-minute rate should be at least equal to 30-minute rate"],
"": ["Pricing seems unrealistic. Please verify your rates."]
}
}
Summary Checklist
- <input type="checkbox" disabled=""> Install FluentValidation NuGet packages
- <input type="checkbox" disabled=""> Create
UpdateMentorProfileValidator.csinCore/Validators/ - <input type="checkbox" disabled=""> Extend
AbstractValidator<UpdateMentorProfileDto> - <input type="checkbox" disabled=""> Define validation rules for each property
- <input type="checkbox" disabled=""> Add custom validation methods for complex rules
- <input type="checkbox" disabled=""> Use
.When()for conditional validation - <input type="checkbox" disabled=""> Register validators in
DependencyInjection.cs - <input type="checkbox" disabled=""> Enable
FluentValidationAutoValidation()for automatic validation
Task T059: Implement Controller
What is a Controller?
A Controller is the entry point for HTTP requests:
- Receives HTTP requests (GET, POST, PUT, DELETE)
- Calls service methods to execute business logic
- Returns HTTP responses (200 OK, 404 Not Found, etc.)
- Handles authentication and authorization
Controller Responsibilities
| Responsibility | Example |
|---|---|
| Route handling | [HttpGet("{id}")] |
| Input validation | Model binding, FluentValidation |
| Authorization | [Authorize], [Authorize(Roles = "Admin")] |
| Call services | await _mentorService.GetMentorProfileAsync() |
| Transform responses | Return DTOs as JSON |
| Error handling | Try-catch, return appropriate status codes |
Controllers should be thin! Keep business logic in services.
Implementation Steps
Location: Backend/CareerRoute.API/Controllers/MentorsController.cs
Step 1: Create MentorsController
using CareerRoute.API.Filters;
using CareerRoute.Core.Constants;
using CareerRoute.Core.DTOs.Mentors;
using CareerRoute.Core.Exceptions;
using CareerRoute.Core.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
namespace CareerRoute.API.Controllers
{
/// <summary>
/// Controller for mentor-related operations.
/// Handles HTTP requests for mentor profiles, applications, and search.
/// </summary>
[Route("api/[controller]")]
[ApiController]
public class MentorsController : ControllerBase
{
private readonly IMentorService _mentorService;
private readonly ILogger<MentorsController> _logger;
/// <summary>
/// Constructor: Inject dependencies
/// </summary>
public MentorsController(
IMentorService mentorService,
ILogger<MentorsController> logger)
{
_mentorService = mentorService;
_logger = logger;
}
// ============ PUBLIC ENDPOINTS (No Authentication Required) ============
/// <summary>
/// Get all approved mentors.
/// Public endpoint - anyone can view approved mentors.
/// </summary>
/// <returns>List of mentor profiles</returns>
[HttpGet]
[ProducesResponseType(typeof(IEnumerable<MentorProfileDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<MentorProfileDto>>> GetAllMentors()
{
_logger.LogInformation("GET /api/mentors - Fetching all approved mentors");
var mentors = await _mentorService.GetAllApprovedMentorsAsync();
return Ok(mentors);
}
/// <summary>
/// Get a specific mentor by ID.
/// Public endpoint - anyone can view mentor profiles.
/// </summary>
/// <param name="id">Mentor ID (same as User ID)</param>
/// <returns>Mentor profile</returns>
[HttpGet("{id}")]
[ProducesResponseType(typeof(MentorProfileDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<MentorProfileDto>> GetMentorById(string id)
{
_logger.LogInformation("GET /api/mentors/{Id} - Fetching mentor profile", id);
var mentor = await _mentorService.GetMentorProfileAsync(id);
if (mentor == null)
{
_logger.LogWarning("Mentor with ID {Id} not found", id);
return NotFound(new { message = $"Mentor with ID {id} not found" });
}
return Ok(mentor);
}
/// <summary>
/// Search mentors by keywords.
/// Public endpoint - anyone can search mentors.
/// </summary>
/// <param name="searchTerm">Search keywords (searches in bio, expertise, name)</param>
/// <returns>List of matching mentors</returns>
[HttpGet("search")]
[ProducesResponseType(typeof(IEnumerable<MentorProfileDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<MentorProfileDto>>> SearchMentors([FromQuery] string searchTerm)
{
_logger.LogInformation("GET /api/mentors/search?searchTerm={SearchTerm}", searchTerm);
var mentors = await _mentorService.SearchMentorsAsync(searchTerm);
return Ok(mentors);
}
/// <summary>
/// Get top-rated mentors.
/// Public endpoint - used for homepage "Featured Mentors".
/// </summary>
/// <param name="count">Number of mentors to return (default 10)</param>
/// <returns>List of top-rated mentors</returns>
[HttpGet("top-rated")]
[ProducesResponseType(typeof(IEnumerable<MentorProfileDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<MentorProfileDto>>> GetTopRatedMentors([FromQuery] int count = 10)
{
_logger.LogInformation("GET /api/mentors/top-rated?count={Count}", count);
if (count <= 0 || count > 100)
{
return BadRequest(new { message = "Count must be between 1 and 100" });
}
var mentors = await _mentorService.GetTopRatedMentorsAsync(count);
return Ok(mentors);
}
// ============ USER ENDPOINTS (Authentication Required) ============
/// <summary>
/// Apply to become a mentor (create mentor profile).
/// User must be authenticated but not already a mentor.
/// </summary>
/// <param name="mentorData">Mentor profile data</param>
/// <returns>Created mentor profile</returns>
[HttpPost]
[Authorize] // Requires logged-in user
[ProducesResponseType(typeof(MentorProfileDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<MentorProfileDto>> ApplyAsMentor([FromBody] UpdateMentorProfileDto mentorData)
{
// Get current user ID from JWT token
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(userId))
{
_logger.LogError("User ID not found in token claims");
return Unauthorized(new { message = "Invalid authentication token" });
}
_logger.LogInformation("POST /api/mentors - User {UserId} applying as mentor", userId);
try
{
var createdMentor = await _mentorService.CreateMentorProfileAsync(userId, mentorData);
_logger.LogInformation("Mentor profile created for user {UserId}", userId);
// Return 201 Created with Location header
return CreatedAtAction(
nameof(GetMentorById),
new { id = createdMentor.Id },
createdMentor);
}
catch (BadRequestException ex)
{
_logger.LogWarning("Bad request: {Message}", ex.Message);
return BadRequest(new { message = ex.Message });
}
catch (NotFoundException ex)
{
_logger.LogError("User not found: {Message}", ex.Message);
return NotFound(new { message = ex.Message });
}
}
/// <summary>
/// Update mentor profile.
/// Only the mentor themselves can update their profile.
/// </summary>
/// <param name="id">Mentor ID to update</param>
/// <param name="updateDto">Updated profile data</param>
/// <returns>Updated mentor profile</returns>
[HttpPut("{id}")]
[Authorize] // Requires logged-in user
[ProducesResponseType(typeof(MentorProfileDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<MentorProfileDto>> UpdateMentorProfile(
string id,
[FromBody] UpdateMentorProfileDto updateDto)
{
// Get current user ID from JWT token
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(userId))
{
return Unauthorized(new { message = "Invalid authentication token" });
}
// Check if user is updating their own profile
if (userId != id)
{
_logger.LogWarning("User {UserId} attempted to update mentor {MentorId}'s profile", userId, id);
return Forbid(); // 403 Forbidden
}
_logger.LogInformation("PUT /api/mentors/{Id} - Updating mentor profile", id);
try
{
var updatedMentor = await _mentorService.UpdateMentorProfileAsync(id, updateDto);
_logger.LogInformation("Mentor profile updated successfully for ID: {Id}", id);
return Ok(updatedMentor);
}
catch (NotFoundException ex)
{
_logger.LogError("Mentor not found: {Message}", ex.Message);
return NotFound(new { message = ex.Message });
}
}
// ============ ADMIN ENDPOINTS (Admin Role Required) ============
/// <summary>
/// Get all mentors pending approval.
/// Admin-only endpoint.
/// </summary>
/// <returns>List of pending mentor applications</returns>
[HttpGet("pending")]
[Authorize(Roles = "Admin")] // Only admins
[ProducesResponseType(typeof(IEnumerable<MentorProfileDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<IEnumerable<MentorProfileDto>>> GetPendingMentors()
{
_logger.LogInformation("GET /api/mentors/pending - Fetching pending mentor applications");
var mentors = await _mentorService.GetPendingMentorApplicationsAsync();
return Ok(mentors);
}
/// <summary>
/// Approve a mentor application.
/// Admin-only endpoint.
/// </summary>
/// <param name="id">Mentor ID to approve</param>
/// <returns>Success message</returns>
[HttpPut("{id}/approve")]
[Authorize(Roles = "Admin")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult> ApproveMentor(string id)
{
_logger.LogInformation("PUT /api/mentors/{Id}/approve - Approving mentor", id);
try
{
await _mentorService.ApproveMentorAsync(id);
_logger.LogInformation("Mentor {Id} approved successfully", id);
return Ok(new { message = "Mentor approved successfully" });
}
catch (NotFoundException ex)
{
_logger.LogError("Mentor not found: {Message}", ex.Message);
return NotFound(new { message = ex.Message });
}
}
/// <summary>
/// Reject a mentor application.
/// Admin-only endpoint.
/// </summary>
/// <param name="id">Mentor ID to reject</param>
/// <param name="reason">Rejection reason (sent to applicant)</param>
/// <returns>Success message</returns>
[HttpPut("{id}/reject")]
[Authorize(Roles = "Admin")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult> RejectMentor(string id, [FromBody] string reason)
{
_logger.LogInformation("PUT /api/mentors/{Id}/reject - Rejecting mentor", id);
if (string.IsNullOrWhiteSpace(reason))
{
return BadRequest(new { message = "Rejection reason is required" });
}
try
{
await _mentorService.RejectMentorAsync(id, reason);
_logger.LogInformation("Mentor {Id} rejected successfully", id);
return Ok(new { message = "Mentor rejected successfully" });
}
catch (NotFoundException ex)
{
_logger.LogError("Mentor not found: {Message}", ex.Message);
return NotFound(new { message = ex.Message });
}
}
}
}
Understanding the Code
1. What is [Route("api/[controller]")]?
- Defines base route:
/api/mentors(plural) [controller]is replaced by controller name minus "Controller"MentorsController→ route is/api/mentors
2. What is [ApiController]?
- Enables API-specific features:
- Automatic model validation (FluentValidation)
- Automatic
400 Bad Requestfor invalid models - Infers
[FromBody],[FromQuery]attributes
3. What is [HttpGet], [HttpPost], etc.?
- Defines HTTP method for the action
[HttpGet]→ GET request[HttpPost]→ POST request (create)[HttpPut]→ PUT request (update)[HttpDelete]→ DELETE request (delete)
4. What is [HttpGet("{id}")]?
- Route parameter:
/api/mentors/{id} - Example:
/api/mentors/abc123→id = "abc123" - Binds
idparameter to method argument
5. What is [Authorize]?
- Requires authenticated user (JWT token)
- Without it: Endpoint is public
- Returns
401 Unauthorizedif no token
6. What is [Authorize(Roles = "Admin")]?
- Requires user to have "Admin" role
- Returns
403 Forbiddenif user is not admin
7. What is User.FindFirstValue(ClaimTypes.NameIdentifier)?
- Gets current user's ID from JWT token
User: Represents authenticated user (from controller base class)ClaimTypes.NameIdentifier: Standard claim for user ID
8. What is CreatedAtAction()?
- Returns
201 CreatedwithLocationheader - Example:
HTTP/1.1 201 Created Location: /api/mentors/abc123 - Points to the newly created resource
9. What is [ProducesResponseType]?
- Documents possible response types for Swagger/OpenAPI
- Example:
[ProducesResponseType(typeof(MentorProfileDto), 200)] - Swagger shows "Returns 200 OK with MentorProfileDto"
10. Why wrap responses in anonymous objects?
return NotFound(new { message = "Mentor not found" });
- Standardized error format
- Frontend can display:
error.message - Better than plain string:
return NotFound("Mentor not found")
11. Why try-catch in controllers?
- Catch service-layer exceptions
- Convert to appropriate HTTP status codes:
NotFoundException→ 404 Not FoundBadRequestException→ 400 Bad Request- Generic
Exception→ 500 Internal Server Error (handled by middleware)
API Endpoints Summary
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /api/mentors |
None | Get all approved mentors |
| GET | /api/mentors/{id} |
None | Get mentor by ID |
| GET | /api/mentors/search |
None | Search mentors by keywords |
| GET | /api/mentors/top-rated |
None | Get top-rated mentors |
| POST | /api/mentors |
User | Apply to become a mentor |
| PUT | /api/mentors/{id} |
User | Update own mentor profile |
| GET | /api/mentors/pending |
Admin | Get pending mentor applications |
| PUT | /api/mentors/{id}/approve |
Admin | Approve mentor application |
| PUT | /api/mentors/{id}/reject |
Admin | Reject mentor application |
Summary Checklist
- <input type="checkbox" disabled=""> Create
MentorsController.csinAPI/Controllers/ - <input type="checkbox" disabled=""> Add
[Route]and[ApiController]attributes - <input type="checkbox" disabled=""> Inject
IMentorServiceandILogger - <input type="checkbox" disabled=""> Implement public endpoints (no auth)
- <input type="checkbox" disabled=""> Implement user endpoints (
[Authorize]) - <input type="checkbox" disabled=""> Implement admin endpoints (
[Authorize(Roles = "Admin")]) - <input type="checkbox" disabled=""> Use proper HTTP methods (GET, POST, PUT)
- <input type="checkbox" disabled=""> Return appropriate status codes (200, 201, 400, 404)
- <input type="checkbox" disabled=""> Add
[ProducesResponseType]for Swagger documentation - <input type="checkbox" disabled=""> Handle exceptions and return error messages
Task T060: Create AutoMapper Profile
What is AutoMapper?
AutoMapper is a library that automates object-to-object mapping:
- Eliminates boilerplate code
- Example: Convert
Mentorentity toMentorProfileDto - Convention-based: Maps properties with same names automatically
- Customizable: You can define custom mappings
Why Use AutoMapper?
Without AutoMapper:
var dto = new MentorProfileDto
{
Id = mentor.Id,
FirstName = mentor.User.FirstName,
LastName = mentor.User.LastName,
Email = mentor.User.Email,
Bio = mentor.Bio,
ExpertiseTags = mentor.ExpertiseTags?.Split(',').ToList() ?? new(),
// ... 15 more properties
};
With AutoMapper:
var dto = _mapper.Map<MentorProfileDto>(mentor);
Implementation Steps
Location: Backend/CareerRoute.Core/Mappings/MentorProfile.cs
Step 1: Update MentorProfile Mapping Class
using AutoMapper;
using CareerRoute.Core.Domain.Entities;
using CareerRoute.Core.DTOs.Mentors;
namespace CareerRoute.Core.Mappings
{
/// <summary>
/// AutoMapper profile for Mentor entity.
/// Defines mappings between Mentor entity and DTOs.
/// </summary>
public class MentorProfile : Profile
{
public MentorProfile()
{
// ============ ENTITY → DTO MAPPINGS ============
/// <summary>
/// Map Mentor entity to MentorProfileDto.
/// Used when reading mentor data from database.
/// </summary>
CreateMap<Mentor, MentorProfileDto>()
// ---- Map User properties (from navigation property) ----
.ForMember(dest => dest.FirstName,
opt => opt.MapFrom(src => src.User.FirstName))
.ForMember(dest => dest.LastName,
opt => opt.MapFrom(src => src.User.LastName))
.ForMember(dest => dest.Email,
opt => opt.MapFrom(src => src.User.Email))
.ForMember(dest => dest.ProfilePictureUrl,
opt => opt.MapFrom(src => src.User.ProfilePictureUrl))
// ---- Transform ExpertiseTags from comma-separated string to list ----
.ForMember(dest => dest.ExpertiseTags,
opt => opt.MapFrom(src =>
string.IsNullOrWhiteSpace(src.ExpertiseTags)
? new List<string>()
: src.ExpertiseTags.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(tag => tag.Trim())
.ToList()))
// ---- All other properties are mapped automatically by convention ----
// AutoMapper maps: Id → Id, Bio → Bio, Rate30Min → Rate30Min, etc.
;
/// <summary>
/// Map UpdateMentorProfileDto to Mentor entity.
/// Used when updating mentor profile.
/// </summary>
CreateMap<UpdateMentorProfileDto, Mentor>()
// All properties match by name and type, so no custom mapping needed
// AutoMapper will map: Bio → Bio, Rate30Min → Rate30Min, etc.
;
// ============ REVERSE MAPPING (optional) ============
/// <summary>
/// Reverse mapping: MentorProfileDto → Mentor
/// Useful if you need to reconstruct entity from DTO (rarely needed).
/// </summary>
// CreateMap<MentorProfileDto, Mentor>().ReverseMap();
}
}
}
Understanding the Code
1. Why extend Profile?
Profileis AutoMapper's base class for mapping configurations- Each entity/DTO group gets its own profile class
- Profiles are automatically discovered and registered
2. What is CreateMap<Source, Destination>()?
- Defines a mapping from
Sourcetype toDestinationtype - Example:
CreateMap<Mentor, MentorProfileDto>()→ Mentor to DTO
3. What is .ForMember()?
- Configures how a specific destination property is mapped
- Required when property names don't match or need transformation
4. What is .MapFrom()?
- Specifies the source value for a property
- Example:
.ForMember(dest => dest.FirstName, opt => opt.MapFrom(src => src.User.FirstName))Maps
mentor.User.FirstName→dto.FirstName
5. Why split ExpertiseTags?
- Entity:
string?(comma-separated:"C#, ASP.NET, Azure") - DTO:
List<string>(array:["C#", "ASP.NET", "Azure"]) - Frontend prefers arrays for easier rendering
6. What is StringSplitOptions.RemoveEmptyEntries?
- Removes empty strings from split result
- Example:
"C#, , Azure"→["C#", "Azure"](skips empty)
7. What is .Trim()?
- Removes leading/trailing spaces
- Example:
" C# "→"C#"
8. Why map UpdateMentorProfileDto → Mentor?
- Used when updating entity from DTO
- Example in service:
var mentor = await _mentorRepository.GetByIdAsync(id); _mapper.Map(updateDto, mentor); // Updates mentor properties from DTO
9. What is .ReverseMap()?
- Creates two-way mapping:
A → BandB → A - Example:
CreateMap<Mentor, MentorProfileDto>().ReverseMap();Creates both
Mentor → MentorProfileDtoandMentorProfileDto → Mentor
10. What if properties match by name?
- AutoMapper maps them automatically!
- Example:
mentor.Id→dto.Id(no configuration needed)
Using AutoMapper in Services
Example 1: Map single entity to DTO
var mentor = await _mentorRepository.GetByIdAsync(id);
var dto = _mapper.Map<MentorProfileDto>(mentor);
return dto;
Example 2: Map collection
var mentors = await _mentorRepository.GetAllAsync();
var dtos = _mapper.Map<IEnumerable<MentorProfileDto>>(mentors);
return dtos;
Example 3: Update entity from DTO
var mentor = await _mentorRepository.GetByIdAsync(id);
_mapper.Map(updateDto, mentor); // Updates mentor's properties
_mentorRepository.Update(mentor);
Testing AutoMapper Configuration
AutoMapper configurations can have errors (typos, missing mappings). Test them:
using Xunit;
using AutoMapper;
using CareerRoute.Core.Mappings;
public class MentorProfileMappingTests
{
private readonly IMapper _mapper;
public MentorProfileMappingTests()
{
var config = new MapperConfiguration(cfg =>
{
cfg.AddProfile<MentorProfile>();
});
_mapper = config.CreateMapper();
}
[Fact]
public void AutoMapper_Configuration_IsValid()
{
// This will throw exception if configuration is invalid
_mapper.ConfigurationProvider.AssertConfigurationIsValid();
}
[Fact]
public void Map_Mentor_To_MentorProfileDto_Success()
{
// Arrange
var mentor = new Mentor
{
Id = "abc123",
Bio = "Experienced developer",
ExpertiseTags = "C#, ASP.NET, Azure",
Rate30Min = 50,
User = new ApplicationUser
{
FirstName = "John",
LastName = "Doe",
Email = "[email protected]"
}
};
// Act
var dto = _mapper.Map<MentorProfileDto>(mentor);
// Assert
Assert.Equal("John", dto.FirstName);
Assert.Equal("Doe", dto.LastName);
Assert.Equal(3, dto.ExpertiseTags.Count);
Assert.Contains("C#", dto.ExpertiseTags);
}
}
Summary Checklist
- <input type="checkbox" disabled=""> Update
MentorProfile.csinCore/Mappings/ - <input type="checkbox" disabled=""> Extend
Profilebase class - <input type="checkbox" disabled=""> Create mapping:
Mentor→MentorProfileDto - <input type="checkbox" disabled=""> Create mapping:
UpdateMentorProfileDto→Mentor - <input type="checkbox" disabled=""> Use
.ForMember()for custom mappings (User properties, ExpertiseTags) - <input type="checkbox" disabled=""> Register AutoMapper in
DependencyInjection.cs - <input type="checkbox" disabled=""> Test mapping configuration
Testing Your Implementation
Step 1: Create Database Migration
cd Backend/CareerRoute.Infrastructure
dotnet ef migrations add AddMentorEntity --startup-project ../CareerRoute.API
dotnet ef database update --startup-project ../CareerRoute.API
Step 2: Register Services in Dependency Injection
Update CareerRoute.Core/DependencyInjection.cs:
services.AddScoped<IMentorRepository, MentorRepository>();
services.AddScoped<IMentorService, MentorService>();
Update CareerRoute.API/Program.cs:
builder.Services.AddCoreServices(); // Registers Core services
builder.Services.AddInfrastructureServices(builder.Configuration); // Registers repositories
Step 3: Test with Postman or Swagger
1. Start the API:
cd Backend/CareerRoute.API
dotnet run
2. Open Swagger UI:
https://localhost:7001/swagger
3. Test Endpoints:
GET /api/mentors (should return empty array initially)
Response: []
POST /api/mentors (apply as mentor - requires authentication)
Headers:
Authorization: Bearer <your-jwt-token>
Body:
{
"bio": "I am an experienced software engineer with 10 years in the industry...",
"expertiseTags": "C#, ASP.NET Core, Azure, Docker",
"yearsOfExperience": 10,
"certifications": "Microsoft Certified: Azure Developer Associate",
"rate30Min": 50.00,
"rate60Min": 90.00
}
Response: 201 Created
{
"id": "abc123",
"firstName": "John",
"lastName": "Doe",
"bio": "I am an experienced software engineer...",
...
}
GET /api/mentors/abc123 (get specific mentor)
Response: 200 OK
{
"id": "abc123",
"firstName": "John",
...
}
PUT /api/mentors/abc123 (update own profile)
Headers:
Authorization: Bearer <your-jwt-token>
Body:
{
"bio": "Updated bio...",
"rate30Min": 55.00,
...
}
Response: 200 OK
Step 4: Verify Database
-- Check Mentors table
SELECT * FROM Mentors;
-- Check relationship with Users
SELECT m.Id, u.FirstName, u.LastName, m.Bio, m.ApprovalStatus
FROM Mentors m
INNER JOIN AspNetUsers u ON m.Id = u.Id;
Common Issues and Solutions
Issue 1: Migration Fails
Error:
The name 'Mentors' is already used by an object.
Solution:
Drop the old table and re-create migration:
dotnet ef migrations remove --startup-project ../CareerRoute.API
dotnet ef migrations add AddMentorEntity --startup-project ../CareerRoute.API
dotnet ef database update --startup-project ../CareerRoute.API
Issue 2: Foreign Key Constraint Error
Error:
The INSERT statement conflicted with the FOREIGN KEY constraint
Solution:
- Ensure User exists before creating Mentor
- In service: Check
await _userRepository.GetByIdAsync(userId)is not null
Issue 3: AutoMapper Null Reference
Error:
AutoMapper.AutoMapperMappingException: Missing type map configuration
Solution:
- Ensure
MentorProfileis in the same assembly asDependencyInjection - Check registration:
services.AddAutoMapper(typeof(DependencyInjection).Assembly);
Issue 4: Validation Not Working
Error:
FluentValidation not validating requests
Solution:
- Ensure
FluentValidationAutoValidation()is called inDependencyInjection - Check validator is registered:
services.AddValidatorsFromAssemblyContaining<UpdateMentorProfileValidator>();
Issue 5: 401 Unauthorized
Error:
401 Unauthorized when calling POST /api/mentors
Solution:
- Ensure JWT token is included:
Authorization: Bearer <token> - Check token expiration
- Verify authentication middleware is configured in
Program.cs
Next Steps
Congratulations! You've completed the Mentor feature implementation. Here's what to do next:
- ✅ Test thoroughly using Postman or Swagger
- ✅ Create unit tests for MentorService methods
- ✅ Implement remaining DTOs (MentorListItemDto for search results)
- ✅ Add Category entity and many-to-many relationship (Task T030, T031)
- ✅ Enhance search with filters (price range, rating, category)
- ✅ Implement frontend components to consume these APIs
Summary
You've learned how to:
- Create domain entities with relationships (Mentor → User)
- Define DTOs for data transfer
- Configure Entity Framework mappings
- Implement Repository Pattern
- Build Service Layer with business logic
- Create validators with FluentValidation
- Implement REST API controllers
- Use AutoMapper for object transformations
This foundation will help you implement the remaining features (Sessions, Payments, Reviews, etc.) using the same patterns.
Keep coding and keep learning! 🚀