Przejdź do treści

Backend - masaku-api

Wprowadzenie

Backend systemu Masaku to aplikacja ASP.NET Core (.NET C#) zbudowana w architekturze warstwowej (layered architecture) z wykorzystaniem Entity Framework Core jako ORM.

Kluczowe technologie

Technologia Wersja Zastosowanie
.NET 6.0+ Framework aplikacji
ASP.NET Core 6.0+ Web API
Entity Framework Core 6.0+ ORM
SQL Server 2019+ Baza danych
Liquibase - Database migrations
Azure Storage - Blob storage (pliki)
Azure AD B2C - Autentykacja
FluentValidation - Walidacja
AutoMapper - Object mapping
Serilog - Logging
xUnit - Testing

Struktura projektu

masaku-api/
├── Masaku.API/                       # Warstwa prezentacji (Web API)
│   ├── Controllers/                  # REST endpoints (34+ kontrolerów)
│   ├── Middlewares/                  # Request/response pipeline
│   ├── Extensions/                   # DI configuration, service setup
│   ├── ApiConventions/               # API conventions i filters
│   ├── Services/                     # Application services
│   ├── Static/                       # Static files
│   ├── appsettings.json              # Configuration
│   ├── appsettings.Development.json
│   ├── appsettings.test.json
│   ├── appsettings.Austria.json
│   ├── appsettings.Production.json
│   ├── Program.cs                    # Entry point
│   └── Startup.cs                    # DI container, middleware
├── Masaku.Services/                  # Warstwa logiki biznesowej
│   ├── Services/                     # Business logic services
│   ├── Validators/                   # FluentValidation validators
│   ├── Mappers/                      # AutoMapper profiles
│   ├── Interfaces/                   # Service interfaces
│   └── DTOs/                         # Data Transfer Objects
├── Masaku.Repository/                # Warstwa dostępu do danych
│   ├── Repositories/                 # Repository implementations
│   ├── Context/                      # EF DbContext
│   ├── Migrations/                   # Liquibase migrations
│   ├── Configurations/               # EF entity configurations
│   └── Interfaces/                   # Repository interfaces
├── Masaku.Domain/                    # Warstwa domenowa
│   ├── Entities/                     # Domain entities
│   ├── Enums/                        # Enumerations
│   ├── ValueObjects/                 # Value objects
│   └── Interfaces/                   # Domain interfaces
├── Masaku.MessageReceiver/           # Background processing
│   ├── Receivers/                    # Message queue receivers
│   └── Handlers/                     # Message handlers
├── Masaku.EmailQueue/                # Email processing
│   ├── Services/                     # Email services
│   └── Templates/                    # Email templates
├── Foresight.Exceptions/             # Custom exception handling
│   └── Exceptions/                   # Custom exception classes
├── Foresight.System/                 # System utilities
│   └── Utilities/                    # Helper classes
├── Masaku.Tests.Unit/                # Unit tests
│   ├── Services/                     # Service tests
│   ├── Repositories/                 # Repository tests
│   └── Controllers/                  # Controller tests
├── Masaku.Tests.Integration/         # Integration tests
│   └── API/                          # API endpoint tests
├── docker/                           # Docker setup
│   ├── docker-compose.yml            # Local services
│   └── Dockerfile                    # API container
├── Sql/                              # Database scripts
│   └── Liquibase/                    # Liquibase changesets
└── azure-pipelines*.yml              # CI/CD pipelines

Layered Architecture

Diagram warstw

┌────────────────────────────────────────────────────────────┐
│                      Masaku.API                            │
│               (Controllers, Middleware)                    │
│                    ▼ HTTP Requests                         │
└─────────────────────────┬──────────────────────────────────┘
                          │ depends on
┌────────────────────────────────────────────────────────────┐
│                   Masaku.Services                          │
│              (Business Logic, Validation)                  │
└─────────────────────────┬──────────────────────────────────┘
                          │ depends on
┌────────────────────────────────────────────────────────────┐
│                  Masaku.Repository                         │
│            (Data Access, EF DbContext)                     │
└─────────────────────────┬──────────────────────────────────┘
                          │ depends on
┌────────────────────────────────────────────────────────────┐
│                    Masaku.Domain                           │
│              (Entities, Interfaces)                        │
└────────────────────────────────────────────────────────────┘
                  ┌───────────────┐
                  │  SQL Server   │
                  └───────────────┘

Separation of Concerns

Warstwa Odpowiedzialność Zależności
API HTTP endpoints, routing, auth Services
Services Business logic, validation, orchestration Repository, Domain
Repository Data access, queries, persistence Domain
Domain Business entities, value objects Brak

Controllers (API Layer)

Przykład kontrolera

// Masaku.API/Controllers/BudgetsController.cs
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class BudgetsController : ControllerBase
{
    private readonly IBudgetService _budgetService;
    private readonly ILogger<BudgetsController> _logger;

    public BudgetsController(
        IBudgetService budgetService,
        ILogger<BudgetsController> logger)
    {
        _budgetService = budgetService;
        _logger = logger;
    }

    /// <summary>
    /// Pobiera wszystkie budżety użytkownika
    /// </summary>
    [HttpGet]
    [ProducesResponseType(typeof(IEnumerable<BudgetDto>), StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status401Unauthorized)]
    public async Task<IActionResult> GetAll()
    {
        var userId = User.GetUserId();
        var budgets = await _budgetService.GetByUserIdAsync(userId);
        return Ok(budgets);
    }

    /// <summary>
    /// Pobiera budżet po ID
    /// </summary>
    [HttpGet("{id}")]
    [ProducesResponseType(typeof(BudgetDto), StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<IActionResult> GetById(int id)
    {
        var budget = await _budgetService.GetByIdAsync(id);

        if (budget == null)
            return NotFound();

        return Ok(budget);
    }

    /// <summary>
    /// Tworzy nowy budżet
    /// </summary>
    [HttpPost]
    [ProducesResponseType(typeof(BudgetDto), StatusCodes.Status201Created)]
    [ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
    public async Task<IActionResult> Create([FromBody] CreateBudgetDto dto)
    {
        var userId = User.GetUserId();
        var budget = await _budgetService.CreateAsync(userId, dto);

        return CreatedAtAction(
            nameof(GetById),
            new { id = budget.Id },
            budget
        );
    }

    /// <summary>
    /// Aktualizuje budżet
    /// </summary>
    [HttpPut("{id}")]
    [ProducesResponseType(typeof(BudgetDto), StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<IActionResult> Update(int id, [FromBody] UpdateBudgetDto dto)
    {
        var budget = await _budgetService.UpdateAsync(id, dto);

        if (budget == null)
            return NotFound();

        return Ok(budget);
    }

    /// <summary>
    /// Usuwa budżet
    /// </summary>
    [HttpDelete("{id}")]
    [ProducesResponseType(StatusCodes.Status204NoContent)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<IActionResult> Delete(int id)
    {
        var result = await _budgetService.DeleteAsync(id);

        if (!result)
            return NotFound();

        return NoContent();
    }
}

API Conventions

// Masaku.API/ApiConventions/CustomApiConventions.cs
public static class CustomApiConventions
{
    [ProducesResponseType(typeof(T), StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status401Unauthorized)]
    [ProducesDefaultResponseType]
    public static void Get<T>() { }

    [ProducesResponseType(typeof(T), StatusCodes.Status201Created)]
    [ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
    [ProducesResponseType(StatusCodes.Status401Unauthorized)]
    [ProducesDefaultResponseType]
    public static void Post<T>() { }
}

Services (Business Logic Layer)

Service interface

// Masaku.Services/Interfaces/IBudgetService.cs
public interface IBudgetService
{
    Task<IEnumerable<BudgetDto>> GetByUserIdAsync(string userId);
    Task<BudgetDto?> GetByIdAsync(int id);
    Task<BudgetDto> CreateAsync(string userId, CreateBudgetDto dto);
    Task<BudgetDto?> UpdateAsync(int id, UpdateBudgetDto dto);
    Task<bool> DeleteAsync(int id);
    Task<BudgetSummaryDto> GetSummaryAsync(int id);
}

Service implementation

// Masaku.Services/Services/BudgetService.cs
public class BudgetService : IBudgetService
{
    private readonly IBudgetRepository _budgetRepository;
    private readonly IMapper _mapper;
    private readonly IValidator<CreateBudgetDto> _createValidator;
    private readonly IValidator<UpdateBudgetDto> _updateValidator;
    private readonly ILogger<BudgetService> _logger;

    public BudgetService(
        IBudgetRepository budgetRepository,
        IMapper mapper,
        IValidator<CreateBudgetDto> createValidator,
        IValidator<UpdateBudgetDto> updateValidator,
        ILogger<BudgetService> logger)
    {
        _budgetRepository = budgetRepository;
        _mapper = mapper;
        _createValidator = createValidator;
        _updateValidator = updateValidator;
        _logger = logger;
    }

    public async Task<IEnumerable<BudgetDto>> GetByUserIdAsync(string userId)
    {
        var budgets = await _budgetRepository.GetByUserIdAsync(userId);
        return _mapper.Map<IEnumerable<BudgetDto>>(budgets);
    }

    public async Task<BudgetDto?> GetByIdAsync(int id)
    {
        var budget = await _budgetRepository.GetByIdAsync(id);
        return budget == null ? null : _mapper.Map<BudgetDto>(budget);
    }

    public async Task<BudgetDto> CreateAsync(string userId, CreateBudgetDto dto)
    {
        // Walidacja
        var validationResult = await _createValidator.ValidateAsync(dto);
        if (!validationResult.IsValid)
        {
            throw new ValidationException(validationResult.Errors);
        }

        // Mapowanie DTO -> Entity
        var budget = _mapper.Map<Budget>(dto);
        budget.UserId = userId;
        budget.CreatedAt = DateTime.UtcNow;

        // Zapis do bazy
        await _budgetRepository.CreateAsync(budget);
        await _budgetRepository.SaveChangesAsync();

        _logger.LogInformation("Budget {BudgetId} created by user {UserId}", budget.Id, userId);

        // Mapowanie Entity -> DTO
        return _mapper.Map<BudgetDto>(budget);
    }

    public async Task<BudgetDto?> UpdateAsync(int id, UpdateBudgetDto dto)
    {
        // Walidacja
        var validationResult = await _updateValidator.ValidateAsync(dto);
        if (!validationResult.IsValid)
        {
            throw new ValidationException(validationResult.Errors);
        }

        var budget = await _budgetRepository.GetByIdAsync(id);
        if (budget == null)
            return null;

        // Mapowanie zmian
        _mapper.Map(dto, budget);
        budget.UpdatedAt = DateTime.UtcNow;

        // Zapis do bazy
        _budgetRepository.Update(budget);
        await _budgetRepository.SaveChangesAsync();

        _logger.LogInformation("Budget {BudgetId} updated", id);

        return _mapper.Map<BudgetDto>(budget);
    }

    public async Task<bool> DeleteAsync(int id)
    {
        var budget = await _budgetRepository.GetByIdAsync(id);
        if (budget == null)
            return false;

        _budgetRepository.Delete(budget);
        await _budgetRepository.SaveChangesAsync();

        _logger.LogInformation("Budget {BudgetId} deleted", id);

        return true;
    }

    public async Task<BudgetSummaryDto> GetSummaryAsync(int id)
    {
        var budget = await _budgetRepository.GetByIdWithExpensesAsync(id);
        if (budget == null)
            throw new NotFoundException($"Budget with ID {id} not found");

        var summary = new BudgetSummaryDto
        {
            BudgetId = budget.Id,
            TotalAmount = budget.Amount,
            SpentAmount = budget.Expenses.Sum(e => e.Amount),
            RemainingAmount = budget.Amount - budget.Expenses.Sum(e => e.Amount),
            ExpenseCount = budget.Expenses.Count
        };

        return summary;
    }
}

DTOs (Data Transfer Objects)

// Masaku.Services/DTOs/BudgetDto.cs
public class BudgetDto
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public decimal Amount { get; set; }
    public DateTime StartDate { get; set; }
    public DateTime? EndDate { get; set; }
    public string UserId { get; set; } = string.Empty;
    public bool Shared { get; set; }
    public string Currency { get; set; } = "EUR";
    public string Status { get; set; } = "Active";
}

public class CreateBudgetDto
{
    public string Name { get; set; } = string.Empty;
    public decimal Amount { get; set; }
    public DateTime StartDate { get; set; }
    public DateTime? EndDate { get; set; }
    public bool Shared { get; set; }
    public string Currency { get; set; } = "EUR";
}

public class UpdateBudgetDto
{
    public string? Name { get; set; }
    public decimal? Amount { get; set; }
    public DateTime? StartDate { get; set; }
    public DateTime? EndDate { get; set; }
    public string? Status { get; set; }
}

Validators (FluentValidation)

// Masaku.Services/Validators/CreateBudgetDtoValidator.cs
public class CreateBudgetDtoValidator : AbstractValidator<CreateBudgetDto>
{
    public CreateBudgetDtoValidator()
    {
        RuleFor(x => x.Name)
            .NotEmpty().WithMessage("Budget name is required")
            .MinimumLength(3).WithMessage("Budget name must be at least 3 characters")
            .MaximumLength(100).WithMessage("Budget name cannot exceed 100 characters");

        RuleFor(x => x.Amount)
            .GreaterThan(0).WithMessage("Budget amount must be greater than 0");

        RuleFor(x => x.StartDate)
            .NotEmpty().WithMessage("Start date is required");

        RuleFor(x => x.EndDate)
            .GreaterThan(x => x.StartDate)
            .When(x => x.EndDate.HasValue)
            .WithMessage("End date must be after start date");

        RuleFor(x => x.Currency)
            .NotEmpty()
            .Must(c => c == "EUR" || c == "USD")
            .WithMessage("Currency must be either EUR or USD");
    }
}

Repository Pattern

Repository interface

// Masaku.Repository/Interfaces/IBudgetRepository.cs
public interface IBudgetRepository
{
    Task<IEnumerable<Budget>> GetByUserIdAsync(string userId);
    Task<Budget?> GetByIdAsync(int id);
    Task<Budget?> GetByIdWithExpensesAsync(int id);
    Task CreateAsync(Budget budget);
    void Update(Budget budget);
    void Delete(Budget budget);
    Task<int> SaveChangesAsync();
}

Repository implementation

// Masaku.Repository/Repositories/BudgetRepository.cs
public class BudgetRepository : IBudgetRepository
{
    private readonly MasakuDbContext _context;

    public BudgetRepository(MasakuDbContext context)
    {
        _context = context;
    }

    public async Task<IEnumerable<Budget>> GetByUserIdAsync(string userId)
    {
        return await _context.Budgets
            .Where(b => b.UserId == userId)
            .OrderByDescending(b => b.CreatedAt)
            .ToListAsync();
    }

    public async Task<Budget?> GetByIdAsync(int id)
    {
        return await _context.Budgets
            .FirstOrDefaultAsync(b => b.Id == id);
    }

    public async Task<Budget?> GetByIdWithExpensesAsync(int id)
    {
        return await _context.Budgets
            .Include(b => b.Expenses)
            .FirstOrDefaultAsync(b => b.Id == id);
    }

    public async Task CreateAsync(Budget budget)
    {
        await _context.Budgets.AddAsync(budget);
    }

    public void Update(Budget budget)
    {
        _context.Budgets.Update(budget);
    }

    public void Delete(Budget budget)
    {
        _context.Budgets.Remove(budget);
    }

    public async Task<int> SaveChangesAsync()
    {
        return await _context.SaveChangesAsync();
    }
}

Domain Entities

// Masaku.Domain/Entities/Budget.cs
public class Budget
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public decimal Amount { get; set; }
    public DateTime StartDate { get; set; }
    public DateTime? EndDate { get; set; }
    public string UserId { get; set; } = string.Empty;
    public bool Shared { get; set; }
    public string Currency { get; set; } = "EUR";
    public BudgetStatus Status { get; set; } = BudgetStatus.Active;

    public DateTime CreatedAt { get; set; }
    public DateTime? UpdatedAt { get; set; }

    // Navigation properties
    public virtual ICollection<Expense> Expenses { get; set; } = new List<Expense>();
    public virtual User User { get; set; } = null!;
}

public enum BudgetStatus
{
    Active,
    Inactive,
    Completed
}

Entity Framework Configuration

// Masaku.Repository/Configurations/BudgetConfiguration.cs
public class BudgetConfiguration : IEntityTypeConfiguration<Budget>
{
    public void Configure(EntityTypeBuilder<Budget> builder)
    {
        builder.ToTable("Budgets");

        builder.HasKey(b => b.Id);

        builder.Property(b => b.Name)
            .IsRequired()
            .HasMaxLength(100);

        builder.Property(b => b.Amount)
            .HasColumnType("decimal(18,2)")
            .IsRequired();

        builder.Property(b => b.Currency)
            .HasMaxLength(3)
            .IsRequired();

        builder.Property(b => b.UserId)
            .IsRequired()
            .HasMaxLength(450);

        builder.Property(b => b.Status)
            .HasConversion<string>()
            .HasMaxLength(20);

        // Indexes
        builder.HasIndex(b => b.UserId);
        builder.HasIndex(b => b.CreatedAt);

        // Relationships
        builder.HasOne(b => b.User)
            .WithMany(u => u.Budgets)
            .HasForeignKey(b => b.UserId)
            .OnDelete(DeleteBehavior.Cascade);

        builder.HasMany(b => b.Expenses)
            .WithOne(e => e.Budget)
            .HasForeignKey(e => e.BudgetId)
            .OnDelete(DeleteBehavior.Restrict);
    }
}

Dependency Injection

// Masaku.API/Extensions/ServiceCollectionExtensions.cs
public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddApplicationServices(
        this IServiceCollection services,
        IConfiguration configuration)
    {
        // DbContext
        services.AddDbContext<MasakuDbContext>(options =>
            options.UseSqlServer(configuration.GetConnectionString("DefaultConnection")));

        // Repositories
        services.AddScoped<IBudgetRepository, BudgetRepository>();
        services.AddScoped<IClientRepository, ClientRepository>();
        services.AddScoped<IExpenseRepository, ExpenseRepository>();
        // ... więcej repositories

        // Services
        services.AddScoped<IBudgetService, BudgetService>();
        services.AddScoped<IClientService, ClientService>();
        services.AddScoped<IExpenseService, ExpenseService>();
        // ... więcej services

        // Validators
        services.AddValidatorsFromAssemblyContaining<CreateBudgetDtoValidator>();

        // AutoMapper
        services.AddAutoMapper(typeof(BudgetProfile).Assembly);

        // Logging
        services.AddLogging(builder =>
        {
            builder.AddSerilog();
        });

        return services;
    }
}

Middleware

Exception Handling Middleware

// Masaku.API/Middlewares/ExceptionHandlingMiddleware.cs
public class ExceptionHandlingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<ExceptionHandlingMiddleware> _logger;

    public ExceptionHandlingMiddleware(
        RequestDelegate next,
        ILogger<ExceptionHandlingMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (ValidationException ex)
        {
            await HandleValidationExceptionAsync(context, ex);
        }
        catch (NotFoundException ex)
        {
            await HandleNotFoundExceptionAsync(context, ex);
        }
        catch (Exception ex)
        {
            await HandleExceptionAsync(context, ex);
        }
    }

    private async Task HandleValidationExceptionAsync(HttpContext context, ValidationException ex)
    {
        _logger.LogWarning(ex, "Validation error occurred");

        context.Response.StatusCode = StatusCodes.Status400BadRequest;
        context.Response.ContentType = "application/json";

        var errors = ex.Errors.Select(e => new
        {
            Property = e.PropertyName,
            Message = e.ErrorMessage
        });

        await context.Response.WriteAsJsonAsync(new
        {
            Title = "Validation Error",
            Status = 400,
            Errors = errors
        });
    }

    private async Task HandleNotFoundExceptionAsync(HttpContext context, NotFoundException ex)
    {
        _logger.LogWarning(ex, "Resource not found");

        context.Response.StatusCode = StatusCodes.Status404NotFound;
        context.Response.ContentType = "application/json";

        await context.Response.WriteAsJsonAsync(new
        {
            Title = "Not Found",
            Status = 404,
            Detail = ex.Message
        });
    }

    private async Task HandleExceptionAsync(HttpContext context, Exception ex)
    {
        _logger.LogError(ex, "Unhandled exception occurred");

        context.Response.StatusCode = StatusCodes.Status500InternalServerError;
        context.Response.ContentType = "application/json";

        await context.Response.WriteAsJsonAsync(new
        {
            Title = "Internal Server Error",
            Status = 500,
            Detail = "An unexpected error occurred"
        });
    }
}

Logging Middleware

// Masaku.API/Middlewares/RequestLoggingMiddleware.cs
public class RequestLoggingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<RequestLoggingMiddleware> _logger;

    public RequestLoggingMiddleware(
        RequestDelegate next,
        ILogger<RequestLoggingMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        var stopwatch = Stopwatch.StartNew();

        _logger.LogInformation(
            "Request {Method} {Path} started",
            context.Request.Method,
            context.Request.Path
        );

        await _next(context);

        stopwatch.Stop();

        _logger.LogInformation(
            "Request {Method} {Path} completed with status {StatusCode} in {ElapsedMs}ms",
            context.Request.Method,
            context.Request.Path,
            context.Response.StatusCode,
            stopwatch.ElapsedMilliseconds
        );
    }
}

AutoMapper

// Masaku.Services/Mappers/BudgetProfile.cs
public class BudgetProfile : Profile
{
    public BudgetProfile()
    {
        // Entity -> DTO
        CreateMap<Budget, BudgetDto>();

        // DTO -> Entity
        CreateMap<CreateBudgetDto, Budget>()
            .ForMember(dest => dest.Id, opt => opt.Ignore())
            .ForMember(dest => dest.UserId, opt => opt.Ignore())
            .ForMember(dest => dest.CreatedAt, opt => opt.Ignore())
            .ForMember(dest => dest.UpdatedAt, opt => opt.Ignore());

        // Update DTO -> Entity (partial update)
        CreateMap<UpdateBudgetDto, Budget>()
            .ForAllMembers(opts => opts.Condition((src, dest, srcMember) =>
                srcMember != null));
    }
}

Dalsze zasoby