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