Backend¶
Technology Stack¶
- .NET 11 (Preview) - framework
- Entity Framework Core 11 (Preview) - ORM with PostgreSQL
- FluentValidation - request validation (Organizer.Api, Portfolio.Api, Users.Api)
- Microsoft.Extensions.AI - AI integration (IChatClient)
- Serilog - structured logging
- OpenTelemetry - distributed tracing & metrics
Project Structure¶
├── Questions.Api/ # Interview questions management
├── Organizer.Api/ # Job offers & interviews
├── Portfolio.Api/ # CV portfolio builder
├── Users.Api/ # User management & auth
└── Recron.AppHost/ # Aspire orchestration
Shared libraries are consumed as NuGet packages from GitHub Packages:
| Package | Description |
|---|---|
Extensions.Application |
CQRS abstractions (ICommand, IQuery, handlers) |
Extensions.Domain |
Entities, domain abstractions, exceptions |
Extensions.Infrastructure |
Database, auth, caching, exception handling, cross-cutting concerns |
API Services¶
Questions.Api (14 endpoints)¶
| Feature | Type | Description |
|---|---|---|
| GetCategories | Query | List all categories with hierarchy |
| GetQuestions | Query | Get questions by category |
| ProposeQuestion | Command | User proposes new question (Pending) |
| ApproveQuestion | Command | Moderator approves question |
| RejectQuestion | Command | Moderator rejects question |
| DeleteQuestion | Command | Delete question |
| PromoteQuestion | Command | Upvote question |
| UpdateQuestion | Command | Edit question name |
| UpdateAnswer | Command | Edit question description/answer |
| ReorderQuestion | Command | Reorder within category |
| AddToLearning | Command | Add to learning list |
| RemoveFromLearning | Command | Remove from learning list |
| AskAi | Query | Generate AI answer |
| GetQuiz | Query | Get random quiz questions from learning list |
Organizer.Api (23 endpoints)¶
Offers (14):
| Feature | Method | Route | Description |
|---|---|---|---|
| GetOffers | Query | GET /offers |
List offers filtered by status |
| GetOffersCounts | Query | GET /offers/counts |
Count offers by status |
| CompareOffers | Query | GET /offers/compare |
Compare selected offers side by side |
| GetOffersStats | Query | GET /offers/stats |
Aggregated analytics dashboard data |
| ParseOfferAi | Query | POST /offers/parse-ai |
AI-parse offer from URL or text |
| AddOffer | Command | POST /offers |
Create job offer |
| UpdateOffer | Command | PUT /offers/{id} |
Edit offer details |
| UpdateOfferState | Command | PATCH /offers/{id} |
Change status with reasons |
| UpdateOfferOrder | Command | PATCH /offers/{id}/order |
Reorder offers via drag & drop |
| DeleteOffer | Command | DELETE /offers/{id} |
Remove offer |
| DuplicateOffer | Command | POST /offers/{id}/duplicate |
Duplicate offer with all fields |
| MatchCv | Query | POST /offers/{id}/match-cv |
AI match CV against offer |
| ScoreOffer | Command | POST /offers/{id}/score |
AI score offer compatibility |
| GenerateCoverLetter | Command | POST /offers/{id}/cover-letter |
AI generate cover letter |
Interviews (4):
| Feature | Method | Route | Description |
|---|---|---|---|
| GetInterviews | Query | GET /offers/{id}/interviews |
List interviews for offer |
| AddInterview | Command | POST /interviews |
Schedule interview |
| UpdateInterview | Command | PUT /interviews/{id} |
Edit interview |
| DeleteInterview | Command | DELETE /interviews/{id} |
Remove interview |
Google Calendar (4):
| Feature | Method | Route | Description |
|---|---|---|---|
| GetGoogleCalendarStatus | Query | GET /calendar/status |
Check OAuth connection status |
| ConnectGoogleCalendar | Query | GET /calendar/connect |
Start OAuth flow (returns redirect URL) |
| GoogleCalendarOAuthCallback | Command | POST /calendar/oauth/callback |
Handle OAuth callback, store tokens |
| DisconnectGoogleCalendar | Command | DELETE /calendar/disconnect |
Revoke OAuth tokens |
Dictionaries (1):
| Feature | Method | Route | Description |
|---|---|---|---|
| GetDictionaries | Query | GET /dictionaries/{route} |
Reference data by key (positions, cities, reasons) |
Portfolio.Api (7 endpoints)¶
| Feature | Method | Route | Description |
|---|---|---|---|
| GetPortfolio | Query | GET /portfolio |
Get single portfolio by id (or default) |
| GetPortfolios | Query | GET /portfolios |
List all user's portfolio variants |
| SavePortfolio | Command | PUT /portfolio |
Create or update portfolio |
| DeletePortfolio | Command | DELETE /portfolio/{id} |
Delete portfolio variant |
| ParseLinkedIn | Query | POST /portfolio/parse-linkedin |
AI-parse portfolio from LinkedIn URL |
| ParseCvPdf | Query | POST /portfolio/parse-cv-pdf |
AI-parse portfolio from uploaded PDF |
| CheckAts | Query | POST /portfolio/check-ats |
AI check ATS compatibility of portfolio |
Users.Api (3 endpoints)¶
| Feature | Type | Description |
|---|---|---|
| GetTokenDetails | Query | Return authenticated user's claims |
| GetProfile | Query | Get user profile |
| SaveProfile | Command | Create or update user profile |
CQRS Pattern¶
Abstractions (Extensions.Application NuGet package)¶
// Commands
public interface ICommand { }
public interface ICommand<TResponse> { }
// Queries
public interface IQuery<TResponse> { }
// Handlers
public interface ICommandHandler<in TCommand> where TCommand : ICommand
{
Task ExecuteAsync(TCommand command, CancellationToken ct = default);
}
public interface ICommandHandler<in TCommand, TResponse> where TCommand : ICommand<TResponse>
{
Task<TResponse> ExecuteAsync(TCommand command, CancellationToken ct = default);
}
public interface IQueryHandler<in TQuery, TResponse> where TQuery : IQuery<TResponse>
{
Task<TResponse> ExecuteAsync(TQuery query, CancellationToken ct = default);
}
Example: Propose Question¶
// Command
public record ProposeQuestionCommand(Guid CategoryId, string Name) : ICommand;
// Handler
public sealed class ProposeQuestionHandler(
ApplicationDbContext dbContext,
ICurrentUserService userService)
: ICommandHandler<ProposeQuestionCommand>
{
public async Task ExecuteAsync(
ProposeQuestionCommand command,
CancellationToken ct = default)
{
var userId = userService.UserId
?? throw new UnauthorizedAccessException();
var question = new QuestionEntity
{
CategoryId = command.CategoryId,
Name = command.Name,
Status = QuestionStatus.Pending
};
dbContext.Questions.Add(question);
await dbContext.SaveChangesAsync(ct);
}
}
// Endpoint
public static IEndpointRouteBuilder MapProposeQuestion(this IEndpointRouteBuilder app)
{
app.MapPost("/questions/questions/propose", async (
ProposeQuestionRequest request,
ProposeQuestionHandler handler,
CancellationToken ct) =>
{
await handler.ExecuteAsync(
new ProposeQuestionCommand(request.CategoryId, request.Name), ct);
return Results.Created();
})
.RequireAuthorization()
.WithName("ProposeQuestion");
return app;
}
Entities¶
Base Classes¶
// Base entity with Id
public abstract class BaseEntity
{
public Guid Id { get; init; }
}
// Entity with audit fields (auto-populated by AuditInterceptor)
public abstract class AuditEntity : BaseEntity
{
public Guid CreatedBy { get; set; }
public DateTimeOffset CreatedOn { get; set; }
public Guid ModifiedBy { get; set; }
public DateTimeOffset ModifiedOn { get; set; }
}
Current User Service¶
public interface ICurrentUserService
{
Guid? UserId { get; }
string? UserName { get; }
IReadOnlyList<string> Roles { get; }
bool IsInRole(string role);
}
// Usage in handlers
public sealed class MyHandler(ICurrentUserService userService)
{
public async Task ExecuteAsync(...)
{
// For queries - allow anonymous
var userId = userService.UserId ?? Guid.Empty;
// For commands - require auth
var userId = userService.UserId
?? throw new UnauthorizedAccessException();
}
}
Roles¶
public static class Roles
{
public const string Admin = "admin";
public const string Moderator = "moderator";
}
Exception Handling¶
Global exception handler maps exceptions to HTTP status codes:
| Exception | HTTP Status |
|---|---|
| ValidationException | 422 Unprocessable Entity |
| DataInconsistencyException | 422 Unprocessable Entity |
| ArgumentException | 422 Unprocessable Entity |
| InvalidOperationException | 409 Conflict |
| KeyNotFoundException | 404 Not Found |
| UnauthorizedAccessException | 403 Forbidden |
| TimeoutException | 408 Request Timeout |
| NotImplementedException | 501 Not Implemented |
| Other | 500 Internal Server Error |
Audit Interceptor¶
Automatically sets audit fields on SaveChanges:
// On Insert
entity.CreatedBy = currentUserId;
entity.CreatedOn = DateTimeOffset.UtcNow;
// On Update
entity.ModifiedBy = currentUserId;
entity.ModifiedOn = DateTimeOffset.UtcNow;
Never set audit fields manually in handlers.
Query Best Practices¶
// Always AsNoTracking for reads
var questions = await db.Questions
.AsNoTracking()
.Where(q => q.CategoryId == categoryId)
.Where(q => q.Status != QuestionStatus.Rejected)
.OrderBy(q => q.Order)
.Select(q => new QuestionResponse(
q.Id,
q.Name,
q.Description,
q.PromoteCount,
q.Votes.Any(v => v.UserId == userId),
q.LearningQuestions.Any(l => l.UserId == userId),
q.Status == QuestionStatus.Pending))
.ToListAsync(ct);
FluentValidation (Organizer.Api, Portfolio.Api)¶
public sealed class AddOfferValidator : AbstractValidator<AddOfferRequest>
{
public AddOfferValidator()
{
RuleFor(x => x.Name).MaximumLength(200);
RuleFor(x => x.Link).MaximumLength(500);
RuleFor(x => x.Company).MaximumLength(200);
RuleFor(x => x.City).MaximumLength(100);
RuleFor(x => x.Description).MaximumLength(2000);
}
}
// In endpoint
app.MapPost("/offers", async (
AddOfferRequest request,
IValidator<AddOfferRequest> validator,
AddOfferHandler handler,
CancellationToken ct) =>
{
await validator.ValidateAndThrowAsync(request, ct);
await handler.ExecuteAsync(request, ct);
return Results.Created();
});
AI Integration¶
Three services use AI via IChatClient from Microsoft.Extensions.AI:
- Questions.Api — AI answer generation for interview questions
- Organizer.Api — AI offer parsing (URL/text), CV-to-offer matching, offer scoring, cover letter generation
- Portfolio.Api — AI portfolio import from LinkedIn URL and PDF upload, ATS compatibility check
public sealed class AskAiHandler(
ApplicationDbContext dbContext,
IChatClient chatClient)
: IQueryHandler<AskAiQuery, AskAiResponse>
{
public async Task<AskAiResponse> ExecuteAsync(
AskAiQuery query,
CancellationToken ct = default)
{
var question = await dbContext.Questions
.FirstOrDefaultAsync(q => q.Id == query.QuestionId, ct)
?? throw new KeyNotFoundException();
var response = await chatClient.GetResponseAsync(
new SystemChatMessage("You are an interview expert..."),
new UserChatMessage(question.Name),
ct);
question.Description = response.Text;
await dbContext.SaveChangesAsync(ct);
return new AskAiResponse(response.Text);
}
}
Pattern for AI features:
- Build
List<ChatMessage>with system + user prompts - Call
chatClient.GetResponseAsync(chatHistory) - Parse
response.Textas JSON usingCleanJsonResponse()helper (strips markdown code fences) - Deserialize with
JsonSerializer.Deserialize<T>(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true })