Przejdź do treści

Warstwy backendowe - szczegółowo

Wprowadzenie

Backend masaku-api implementuje Clean Architecture (czystą architekturę) z wyraźnym podziałem na warstwy. Każda warstwa ma określoną odpowiedzialność i zależności płyną tylko w jednym kierunku (z góry na dół).

Diagram przepływu

HTTP Request
┌──────────────────────┐
│   Controller         │  Masaku.API
│   (HTTP Layer)       │
└──────────┬───────────┘
           │ calls
┌──────────────────────┐
│   Service            │  Masaku.Services
│   (Business Logic)   │
└──────────┬───────────┘
           │ calls
┌──────────────────────┐
│   Repository         │  Masaku.Repository
│   (Data Access)      │
└──────────┬───────────┘
           │ queries
┌──────────────────────┐
│   Entity             │  Masaku.Domain
│   (Domain Model)     │
└──────────┬───────────┘
      Database

Warstwa 1: Masaku.API (Presentation Layer)

Odpowiedzialność

  • Obsługa HTTP requests/responses
  • Routing
  • Autentykacja/autoryzacja
  • Middleware pipeline
  • Dependency injection configuration
  • API documentation (Swagger)

Struktura

Masaku.API/
├── Controllers/                # REST endpoints
│   ├── BudgetsController.cs
│   ├── ClientsController.cs
│   ├── ExpensesController.cs
│   ├── InvoicesController.cs
│   └── ... (34+ kontrolerów)
├── Middlewares/                # Request pipeline
│   ├── ExceptionHandlingMiddleware.cs
│   ├── RequestLoggingMiddleware.cs
│   ├── AuthenticationMiddleware.cs
│   └── PerformanceLoggingMiddleware.cs
├── Extensions/                 # Extension methods
│   ├── ServiceCollectionExtensions.cs
│   ├── ApplicationBuilderExtensions.cs
│   ├── ClaimsPrincipalExtensions.cs
│   └── HttpContextExtensions.cs
├── ApiConventions/             # API conventions
│   ├── CustomApiConventions.cs
│   └── ApiFilters.cs
├── Services/                   # Application services (nie business logic!)
│   ├── CurrentUserService.cs
│   ├── FileUploadService.cs
│   └── EmailService.cs
├── Static/                     # Static files
│   └── ... (CSS, JS, images)
├── Program.cs                  # Application entry point
├── Startup.cs                  # DI container, middleware setup
└── appsettings*.json           # Configuration files

Przykład: Controller

[ApiController]
[Route("api/[controller]")]
[Authorize]
public class BudgetsController : ControllerBase
{
    private readonly IBudgetService _budgetService;
    private readonly ICurrentUserService _currentUserService;
    private readonly ILogger<BudgetsController> _logger;

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

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

    /// <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 = _currentUserService.UserId;
        var budget = await _budgetService.CreateAsync(userId, dto);

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

    // Pozostałe metody...
}

Middleware Pipeline

// Program.cs
public class Program
{
    public static void Main(string[] args)
    {
        var builder = WebApplication.CreateBuilder(args);

        // Configure services
        builder.Services.AddApplicationServices(builder.Configuration);
        builder.Services.AddControllers();
        builder.Services.AddSwaggerGen();

        var app = builder.Build();

        // Configure middleware pipeline (KOLEJNOŚĆ MA ZNACZENIE!)
        if (app.Environment.IsDevelopment())
        {
            app.UseSwagger();
            app.UseSwaggerUI();
        }

        app.UseMiddleware<RequestLoggingMiddleware>();      // 1. Logging
        app.UseMiddleware<ExceptionHandlingMiddleware>();   // 2. Exception handling
        app.UseHttpsRedirection();                          // 3. HTTPS redirect
        app.UseStaticFiles();                               // 4. Static files
        app.UseRouting();                                   // 5. Routing
        app.UseAuthentication();                            // 6. Authentication
        app.UseAuthorization();                             // 7. Authorization
        app.MapControllers();                               // 8. Controllers

        app.Run();
    }
}

Dependency Injection

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

        // Authentication
        services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
            .AddJwtBearer(options =>
            {
                options.Authority = configuration["AzureAdB2C:Authority"];
                options.Audience = configuration["AzureAdB2C:ClientId"];
            });

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

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

        // Application services
        services.AddScoped<ICurrentUserService, CurrentUserService>();
        services.AddScoped<IFileUploadService, FileUploadService>();

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

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

        // Logging (Serilog)
        services.AddSerilog(configuration);

        // CORS
        services.AddCors(options =>
        {
            options.AddPolicy("AllowFrontend", builder =>
            {
                builder.WithOrigins(configuration["FrontendUrl"])
                    .AllowAnyMethod()
                    .AllowAnyHeader()
                    .AllowCredentials();
            });
        });

        return services;
    }
}

Warstwa 2: Masaku.Services (Business Logic Layer)

Odpowiedzialność

  • Logika biznesowa
  • Walidacja danych
  • Orchestracja operacji (transaction coordination)
  • Mapping (DTOs ↔ Entities)
  • Business rules enforcement

Struktura

Masaku.Services/
├── Services/                   # Implementacje logiki biznesowej
│   ├── BudgetService.cs
│   ├── ClientService.cs
│   ├── ExpenseService.cs
│   ├── InvoiceService.cs
│   ├── ReceiptService.cs
│   └── ...
├── Interfaces/                 # Service interfaces
│   ├── IBudgetService.cs
│   ├── IClientService.cs
│   └── ...
├── DTOs/                       # Data Transfer Objects
│   ├── Budget/
│   │   ├── BudgetDto.cs
│   │   ├── CreateBudgetDto.cs
│   │   └── UpdateBudgetDto.cs
│   ├── Client/
│   │   ├── ClientDto.cs
│   │   ├── CreateClientDto.cs
│   │   └── UpdateClientDto.cs
│   └── ...
├── Validators/                 # FluentValidation validators
│   ├── CreateBudgetDtoValidator.cs
│   ├── UpdateBudgetDtoValidator.cs
│   ├── CreateClientDtoValidator.cs
│   └── ...
└── Mappers/                    # AutoMapper profiles
    ├── BudgetProfile.cs
    ├── ClientProfile.cs
    └── ...

Przykład: Service

// Services/BudgetService.cs
public class BudgetService : IBudgetService
{
    private readonly IBudgetRepository _budgetRepository;
    private readonly IExpenseRepository _expenseRepository;
    private readonly IUnitOfWork _unitOfWork;
    private readonly IMapper _mapper;
    private readonly IValidator<CreateBudgetDto> _createValidator;
    private readonly ILogger<BudgetService> _logger;

    public BudgetService(
        IBudgetRepository budgetRepository,
        IExpenseRepository expenseRepository,
        IUnitOfWork unitOfWork,
        IMapper mapper,
        IValidator<CreateBudgetDto> createValidator,
        ILogger<BudgetService> logger)
    {
        _budgetRepository = budgetRepository;
        _expenseRepository = expenseRepository;
        _unitOfWork = unitOfWork;
        _mapper = mapper;
        _createValidator = createValidator;
        _logger = logger;
    }

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

        // 2. Business rules
        var existingBudgets = await _budgetRepository.GetActiveByUserIdAsync(userId);
        if (existingBudgets.Count() >= 10)
        {
            throw new BusinessRuleException("User cannot have more than 10 active budgets");
        }

        // 3. Mapping
        var budget = _mapper.Map<Budget>(dto);
        budget.UserId = userId;
        budget.CreatedAt = DateTime.UtcNow;
        budget.Status = BudgetStatus.Active;

        // 4. Persistence
        await _budgetRepository.CreateAsync(budget);
        await _unitOfWork.SaveChangesAsync();

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

        // 5. Return DTO
        return _mapper.Map<BudgetDto>(budget);
    }

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

        // 2. Calculate summary (Business logic)
        var totalSpent = budget.Expenses.Sum(e => e.Amount);
        var remaining = budget.Amount - totalSpent;
        var percentageUsed = budget.Amount > 0 ? (totalSpent / budget.Amount) * 100 : 0;

        // 3. Return summary
        return new BudgetSummaryDto
        {
            BudgetId = budget.Id,
            Name = budget.Name,
            TotalAmount = budget.Amount,
            SpentAmount = totalSpent,
            RemainingAmount = remaining,
            PercentageUsed = percentageUsed,
            ExpenseCount = budget.Expenses.Count,
            Status = budget.Status.ToString()
        };
    }

    public async Task<bool> TransferFundsAsync(int fromBudgetId, int toBudgetId, decimal amount)
    {
        // Transaction example - orchestracja wielu operacji
        using var transaction = await _unitOfWork.BeginTransactionAsync();

        try
        {
            // 1. Fetch budgets
            var fromBudget = await _budgetRepository.GetByIdAsync(fromBudgetId);
            var toBudget = await _budgetRepository.GetByIdAsync(toBudgetId);

            if (fromBudget == null || toBudget == null)
                throw new NotFoundException("One or both budgets not found");

            // 2. Business rules
            if (fromBudget.Amount < amount)
                throw new BusinessRuleException("Insufficient funds in source budget");

            // 3. Update amounts
            fromBudget.Amount -= amount;
            toBudget.Amount += amount;

            _budgetRepository.Update(fromBudget);
            _budgetRepository.Update(toBudget);

            // 4. Save changes
            await _unitOfWork.SaveChangesAsync();
            await transaction.CommitAsync();

            _logger.LogInformation(
                "Transferred {Amount} from budget {FromBudgetId} to {ToBudgetId}",
                amount, fromBudgetId, toBudgetId
            );

            return true;
        }
        catch (Exception ex)
        {
            await transaction.RollbackAsync();
            _logger.LogError(ex, "Failed to transfer funds");
            throw;
        }
    }
}

DTOs

// DTOs/Budget/BudgetDto.cs
public record BudgetDto
{
    public int Id { get; init; }
    public string Name { get; init; } = string.Empty;
    public decimal Amount { get; init; }
    public DateTime StartDate { get; init; }
    public DateTime? EndDate { get; init; }
    public string UserId { get; init; } = string.Empty;
    public bool Shared { get; init; }
    public string Currency { get; init; } = "EUR";
    public string Status { get; init; } = "Active";
    public DateTime CreatedAt { get; init; }
}

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

public record UpdateBudgetDto
{
    public string? Name { get; init; }
    public decimal? Amount { get; init; }
    public DateTime? EndDate { get; init; }
    public string? Status { get; init; }
}

Validators

// 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")
            .LessThanOrEqualTo(1000000).WithMessage("Budget amount cannot exceed 1,000,000");

        RuleFor(x => x.StartDate)
            .NotEmpty().WithMessage("Start date is required")
            .Must(BeAValidDate).WithMessage("Start date must be a valid date");

        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");
    }

    private bool BeAValidDate(DateTime date)
    {
        return date >= new DateTime(2000, 1, 1) && date <= DateTime.UtcNow.AddYears(10);
    }
}

AutoMapper Profiles

// Mappers/BudgetProfile.cs
public class BudgetProfile : Profile
{
    public BudgetProfile()
    {
        // Entity -> DTO
        CreateMap<Budget, BudgetDto>()
            .ForMember(dest => dest.Status, opt => opt.MapFrom(src => src.Status.ToString()));

        // 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())
            .ForMember(dest => dest.Status, opt => opt.MapFrom(src => BudgetStatus.Active));

        // Update DTO -> Entity (tylko niezerowe wartości)
        CreateMap<UpdateBudgetDto, Budget>()
            .ForAllMembers(opts => opts.Condition((src, dest, srcMember) => srcMember != null));
    }
}

Warstwa 3: Masaku.Repository (Data Access Layer)

Odpowiedzialność

  • Dostęp do bazy danych
  • CRUD operations
  • Queries (LINQ)
  • Entity Framework DbContext
  • Database migrations (Liquibase)

Struktura

Masaku.Repository/
├── Repositories/               # Repository implementations
│   ├── BudgetRepository.cs
│   ├── ClientRepository.cs
│   ├── ExpenseRepository.cs
│   └── ...
├── Interfaces/                 # Repository interfaces
│   ├── IBudgetRepository.cs
│   ├── IClientRepository.cs
│   ├── IUnitOfWork.cs
│   └── ...
├── Context/                    # EF DbContext
│   ├── MasakuDbContext.cs
│   └── DesignTimeDbContextFactory.cs
├── Configurations/             # EF entity configurations
│   ├── BudgetConfiguration.cs
│   ├── ClientConfiguration.cs
│   └── ...
└── Migrations/                 # Liquibase changesets
    └── Liquibase/
        ├── 1_dbchangelog.xml
        ├── 2_dbchangelog.xml
        └── ...

DbContext

// Context/MasakuDbContext.cs
public class MasakuDbContext : DbContext
{
    public MasakuDbContext(DbContextOptions<MasakuDbContext> options)
        : base(options)
    {
    }

    public DbSet<Budget> Budgets { get; set; } = null!;
    public DbSet<Client> Clients { get; set; } = null!;
    public DbSet<Expense> Expenses { get; set; } = null!;
    public DbSet<Invoice> Invoices { get; set; } = null!;
    public DbSet<Receipt> Receipts { get; set; } = null!;
    public DbSet<Order> Orders { get; set; } = null!;
    public DbSet<User> Users { get; set; } = null!;
    // ... więcej DbSets

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);

        // Apply all configurations from assembly
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(MasakuDbContext).Assembly);

        // Global query filters
        modelBuilder.Entity<Budget>().HasQueryFilter(b => !b.IsDeleted);
        modelBuilder.Entity<Client>().HasQueryFilter(c => !c.IsDeleted);
        // ... więcej query filters
    }

    public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
    {
        // Automatyczne ustawianie timestamps
        var entries = ChangeTracker.Entries()
            .Where(e => e.Entity is BaseEntity &&
                       (e.State == EntityState.Added || e.State == EntityState.Modified));

        foreach (var entry in entries)
        {
            var entity = (BaseEntity)entry.Entity;

            if (entry.State == EntityState.Added)
            {
                entity.CreatedAt = DateTime.UtcNow;
            }

            entity.UpdatedAt = DateTime.UtcNow;
        }

        return await base.SaveChangesAsync(cancellationToken);
    }
}

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)
            .AsNoTracking()  // Read-only query, lepszy performance
            .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<IEnumerable<Budget>> GetActiveByUserIdAsync(string userId)
    {
        return await _context.Budgets
            .Where(b => b.UserId == userId && b.Status == BudgetStatus.Active)
            .ToListAsync();
    }

    public async Task<Budget?> GetByIdWithFullDetailsAsync(int id)
    {
        return await _context.Budgets
            .Include(b => b.Expenses)
            .Include(b => b.User)
            .Include(b => b.SharedWith)
            .AsSplitQuery()  // Lepszy performance dla wielu includes
            .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)
    {
        // Soft delete
        budget.IsDeleted = true;
        _context.Budgets.Update(budget);
    }

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

    // Complex query example
    public async Task<IEnumerable<Budget>> SearchAsync(BudgetSearchCriteria criteria)
    {
        var query = _context.Budgets.AsQueryable();

        if (!string.IsNullOrWhiteSpace(criteria.Name))
        {
            query = query.Where(b => b.Name.Contains(criteria.Name));
        }

        if (criteria.MinAmount.HasValue)
        {
            query = query.Where(b => b.Amount >= criteria.MinAmount.Value);
        }

        if (criteria.MaxAmount.HasValue)
        {
            query = query.Where(b => b.Amount <= criteria.MaxAmount.Value);
        }

        if (criteria.Status.HasValue)
        {
            query = query.Where(b => b.Status == criteria.Status.Value);
        }

        if (criteria.StartDate.HasValue)
        {
            query = query.Where(b => b.StartDate >= criteria.StartDate.Value);
        }

        // Paginacja
        query = query
            .Skip((criteria.Page - 1) * criteria.PageSize)
            .Take(criteria.PageSize);

        return await query.ToListAsync();
    }
}

Unit of Work

// Interfaces/IUnitOfWork.cs
public interface IUnitOfWork : IDisposable
{
    IBudgetRepository Budgets { get; }
    IClientRepository Clients { get; }
    IExpenseRepository Expenses { get; }
    // ... więcej repositories

    Task<int> SaveChangesAsync();
    Task<IDbContextTransaction> BeginTransactionAsync();
}

// UnitOfWork.cs
public class UnitOfWork : IUnitOfWork
{
    private readonly MasakuDbContext _context;

    public UnitOfWork(
        MasakuDbContext context,
        IBudgetRepository budgets,
        IClientRepository clients,
        IExpenseRepository expenses)
    {
        _context = context;
        Budgets = budgets;
        Clients = clients;
        Expenses = expenses;
    }

    public IBudgetRepository Budgets { get; }
    public IClientRepository Clients { get; }
    public IExpenseRepository Expenses { get; }

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

    public async Task<IDbContextTransaction> BeginTransactionAsync()
    {
        return await _context.Database.BeginTransactionAsync();
    }

    public void Dispose()
    {
        _context.Dispose();
    }
}

Entity Configuration

// 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()
            .HasDefaultValue("EUR");

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

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

        builder.Property(b => b.CreatedAt)
            .IsRequired()
            .HasDefaultValueSql("GETUTCDATE()");

        builder.Property(b => b.IsDeleted)
            .IsRequired()
            .HasDefaultValue(false);

        // Indexes
        builder.HasIndex(b => b.UserId)
            .HasDatabaseName("IX_Budgets_UserId");

        builder.HasIndex(b => b.CreatedAt)
            .HasDatabaseName("IX_Budgets_CreatedAt");

        builder.HasIndex(b => new { b.UserId, b.Status })
            .HasDatabaseName("IX_Budgets_UserId_Status");

        // 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);
    }
}

Warstwa 4: Masaku.Domain (Domain Layer)

Odpowiedzialność

  • Domain entities (business objects)
  • Enums
  • Value objects
  • Domain interfaces
  • Business rules (w encji)

Struktura

Masaku.Domain/
├── Entities/                   # Domain entities
│   ├── Budget.cs
│   ├── Client.cs
│   ├── Expense.cs
│   ├── Invoice.cs
│   ├── Receipt.cs
│   └── ...
├── ValueObjects/               # Value objects
│   ├── Address.cs
│   ├── Money.cs
│   └── DateRange.cs
├── Enums/                      # Enumerations
│   ├── BudgetStatus.cs
│   ├── ExpenseType.cs
│   ├── InvoiceStatus.cs
│   └── ...
└── Interfaces/                 # Domain interfaces
    └── IEntity.cs

Domain Entity

// Entities/Budget.cs
public class Budget : BaseEntity
{
    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;

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

    // Domain methods (business logic)
    public bool IsActive() => Status == BudgetStatus.Active;

    public bool IsExpired() => EndDate.HasValue && EndDate.Value < DateTime.UtcNow;

    public decimal GetTotalSpent() => Expenses.Sum(e => e.Amount);

    public decimal GetRemainingAmount() => Amount - GetTotalSpent();

    public bool CanAddExpense(decimal expenseAmount)
    {
        if (!IsActive()) return false;
        if (IsExpired()) return false;
        return GetRemainingAmount() >= expenseAmount;
    }

    public void Activate()
    {
        if (Status == BudgetStatus.Inactive)
        {
            Status = BudgetStatus.Active;
        }
    }

    public void Deactivate()
    {
        if (Status == BudgetStatus.Active)
        {
            Status = BudgetStatus.Inactive;
        }
    }

    public void Complete()
    {
        if (Status == BudgetStatus.Active)
        {
            Status = BudgetStatus.Completed;
        }
    }
}

// Base entity
public abstract class BaseEntity
{
    public DateTime CreatedAt { get; set; }
    public DateTime? UpdatedAt { get; set; }
    public bool IsDeleted { get; set; }
}

Value Object

// ValueObjects/Money.cs
public record Money
{
    public decimal Amount { get; init; }
    public string Currency { get; init; } = "EUR";

    public Money(decimal amount, string currency = "EUR")
    {
        if (amount < 0)
            throw new ArgumentException("Amount cannot be negative", nameof(amount));

        if (string.IsNullOrWhiteSpace(currency))
            throw new ArgumentException("Currency cannot be empty", nameof(currency));

        Amount = amount;
        Currency = currency.ToUpper();
    }

    public Money Add(Money other)
    {
        if (Currency != other.Currency)
            throw new InvalidOperationException("Cannot add money with different currencies");

        return new Money(Amount + other.Amount, Currency);
    }

    public Money Subtract(Money other)
    {
        if (Currency != other.Currency)
            throw new InvalidOperationException("Cannot subtract money with different currencies");

        return new Money(Amount - other.Amount, Currency);
    }

    public override string ToString() => $"{Amount:N2} {Currency}";
}

Best Practices

1. Separation of Concerns

Każda warstwa ma dokładnie określoną odpowiedzialność:

  • API: HTTP handling
  • Services: Business logic
  • Repository: Data access
  • Domain: Business entities

2. Dependency Direction

Zależności płyną tylko w jednym kierunku (z góry na dół):

API → Services → Repository → Domain

Domain NIE ma żadnych zależności!

3. Async/Await

Wszystkie operacje I/O są asynchroniczne:

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

4. Dependency Injection

Wszystko przez DI container:

public BudgetService(
    IBudgetRepository budgetRepository,
    IMapper mapper,
    ILogger<BudgetService> logger)
{
    _budgetRepository = budgetRepository;
    _mapper = mapper;
    _logger = logger;
}

5. Repository Pattern

Abstrakcja dostępu do danych:

public interface IBudgetRepository
{
    Task<Budget?> GetByIdAsync(int id);
    Task CreateAsync(Budget budget);
    void Update(Budget budget);
    void Delete(Budget budget);
}

6. Unit of Work

Transakcje obejmujące wiele operacji:

using var transaction = await _unitOfWork.BeginTransactionAsync();
try
{
    await _budgetRepository.CreateAsync(budget);
    await _expenseRepository.CreateAsync(expense);
    await _unitOfWork.SaveChangesAsync();
    await transaction.CommitAsync();
}
catch
{
    await transaction.RollbackAsync();
    throw;
}

Dalsze zasoby