Skip to content

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:

  1. Build List<ChatMessage> with system + user prompts
  2. Call chatClient.GetResponseAsync(chatHistory)
  3. Parse response.Text as JSON using CleanJsonResponse() helper (strips markdown code fences)
  4. Deserialize with JsonSerializer.Deserialize<T>(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true })