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ół):
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;
}