diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 000000000..e12bb365b
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,28 @@
+name: .NET Tests
+
+on:
+ push:
+ branches: [ "*" ]
+ pull_request:
+ branches: [ main ]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 8.0.x
+
+ - name: Restore dependencies
+ run: dotnet restore ./CarRental/CarRental.sln
+
+ - name: Build
+ run: dotnet build ./CarRental/CarRental.sln --no-restore
+
+ - name: Test
+ run: dotnet test ./CarRental/CarRental.Tests/CarRental.Tests.csproj --no-build --verbosity normal
\ No newline at end of file
diff --git a/CarRental/CarRental.Api/CarRental.Api.csproj b/CarRental/CarRental.Api/CarRental.Api.csproj
new file mode 100644
index 000000000..7a2377247
--- /dev/null
+++ b/CarRental/CarRental.Api/CarRental.Api.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net8.0
+ enable
+ True
+ $(NoWarn);1591
+ $(NoWarn);9107
+ enable
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/CarRental/CarRental.Api/Controllers/AnalyticController.cs b/CarRental/CarRental.Api/Controllers/AnalyticController.cs
new file mode 100644
index 000000000..952364f62
--- /dev/null
+++ b/CarRental/CarRental.Api/Controllers/AnalyticController.cs
@@ -0,0 +1,136 @@
+using CarRental.Application.Dtos.Cars;
+using CarRental.Application.Dtos.Customers;
+using CarRental.Application.Services;
+using Microsoft.AspNetCore.Mvc;
+
+namespace CarRental.Api.Controllers;
+
+///
+/// Controller for analytic queries and reports in the car rental system.
+/// Provides endpoints for generating various business intelligence reports and data analytics.
+///
+[ApiController]
+[Produces("application/json")]
+[Route("api/[controller]")]
+public class AnalyticController(IAnalyticQueryService analyticQueryService) : ControllerBase
+{
+ ///
+ /// Retrieves the top 5 most rented cars.
+ ///
+ /// Collection of top 5 cars by rental count
+ /// Returns list of cars
+ /// If there was an internal server error
+ [HttpGet("cars/top5-most-rented")]
+ [ProducesResponseType(typeof(IEnumerable), 200)]
+ [ProducesResponseType(500)]
+ public async Task>> GetTop5MostRentedCars()
+ {
+ try
+ {
+ var result = await analyticQueryService.GetTop5MostRentedCarsAsync();
+ return Ok(result);
+ }
+ catch (InvalidOperationException)
+ {
+ return StatusCode(500);
+ }
+ }
+
+ ///
+ /// Retrieves the number of rentals for each car.
+ ///
+ /// Dictionary with license plate as key and rental count as value
+ /// Returns rental counts per car
+ /// If there was an internal server error
+ [HttpGet("cars/rental-counts")]
+ [ProducesResponseType(typeof(Dictionary), 200)]
+ [ProducesResponseType(500)]
+ public async Task>> GetRentalCountForEachCar()
+ {
+ try
+ {
+ var result = await analyticQueryService.GetRentalCountForEachCarAsync();
+ return Ok(result);
+ }
+ catch (InvalidOperationException)
+ {
+ return StatusCode(500);
+ }
+ }
+
+ ///
+ /// Retrieves the top 5 customers by total rental cost.
+ ///
+ /// Collection of top 5 customers by total spent
+ /// Returns list of customers
+ /// If there was an internal server error
+ [HttpGet("customers/top5-by-total-cost")]
+ [ProducesResponseType(typeof(IEnumerable), 200)]
+ [ProducesResponseType(500)]
+ public async Task>> GetTop5CustomersByTotalCost()
+ {
+ try
+ {
+ var result = await analyticQueryService.GetTop5CustomersByTotalCostAsync();
+ return Ok(result);
+ }
+ catch (InvalidOperationException)
+ {
+ return StatusCode(500);
+ }
+ }
+
+ ///
+ /// Retrieves all customers who rented cars of a specific model.
+ ///
+ /// ID of the car model
+ /// Collection of customers who rented the specified model
+ /// Returns list of customers
+ /// If model ID is invalid
+ /// If there was an internal server error
+ [HttpGet("models/{modelId}/customers")]
+ [ProducesResponseType(typeof(IEnumerable), 200)]
+ [ProducesResponseType(400)]
+ [ProducesResponseType(500)]
+ public async Task>> GetCustomersByModel([FromRoute] int modelId)
+ {
+ try
+ {
+ if (modelId <= 0)
+ return BadRequest(new { error = "Model ID must be positive" });
+
+ var result = await analyticQueryService.GetCustomersByModelAsync(modelId);
+ return Ok(result);
+ }
+ catch (InvalidOperationException)
+ {
+ return StatusCode(500);
+ }
+ }
+
+ ///
+ /// Retrieves cars that are currently rented at the specified moment.
+ ///
+ /// Current date and time (optional, defaults to server time)
+ /// Collection of currently rented cars
+ /// Returns list of cars
+ /// If the provided time is invalid
+ /// If there was an internal server error
+ [HttpGet("cars/currently-rented")]
+ [ProducesResponseType(typeof(IEnumerable), 200)]
+ [ProducesResponseType(400)]
+ [ProducesResponseType(500)]
+ public async Task>> GetCurrentlyRentedCars([FromQuery] DateTime? now = null)
+ {
+ try
+ {
+ var currentTime = now ?? DateTime.UtcNow;
+ var result = await analyticQueryService.GetCurrentlyRentedCarsAsync(currentTime);
+ return Ok(result);
+ }
+ catch (InvalidOperationException)
+ {
+ return StatusCode(500);
+ }
+ }
+}
\ No newline at end of file
diff --git a/CarRental/CarRental.Api/Controllers/CarController.cs b/CarRental/CarRental.Api/Controllers/CarController.cs
new file mode 100644
index 000000000..293d67e91
--- /dev/null
+++ b/CarRental/CarRental.Api/Controllers/CarController.cs
@@ -0,0 +1,41 @@
+using CarRental.Application.Dtos.Cars;
+using CarRental.Application.Dtos.ModelGenerations;
+using CarRental.Application.Services;
+using Microsoft.AspNetCore.Mvc;
+
+namespace CarRental.Api.Controllers;
+
+///
+/// Controller for managing cars in the car rental system.
+///
+public class CarsController(
+ ICrudService service,
+ ICrudService modelGenerationService
+) : CrudControllerBase(service)
+{
+ public override async Task> Create([FromBody] CarCreateDto createDto)
+ {
+ if (!ModelState.IsValid) return BadRequest(ModelState);
+
+ var generation = await modelGenerationService.GetAsync(createDto.ModelGenerationId);
+ if (generation == null)
+ return BadRequest(new { error = $"Model generation with ID {createDto.ModelGenerationId} does not exist" });
+
+ var entity = await service.CreateAsync(createDto);
+ return CreatedAtAction(nameof(GetById), new { id = entity.Id }, entity);
+ }
+
+ public override async Task Update(int id, [FromBody] CarUpdateDto updateDto)
+ {
+ if (!ModelState.IsValid) return BadRequest(ModelState);
+
+ var generation = await modelGenerationService.GetAsync(updateDto.ModelGenerationId);
+ if (generation == null)
+ return BadRequest(new { error = $"Model generation with ID {updateDto.ModelGenerationId} does not exist" });
+
+ var updated = await service.UpdateAsync(id, updateDto);
+ if (updated == null) return NotFound();
+
+ return NoContent();
+ }
+}
\ No newline at end of file
diff --git a/CarRental/CarRental.Api/Controllers/CarModelController.cs b/CarRental/CarRental.Api/Controllers/CarModelController.cs
new file mode 100644
index 000000000..057dba93d
--- /dev/null
+++ b/CarRental/CarRental.Api/Controllers/CarModelController.cs
@@ -0,0 +1,11 @@
+using CarRental.Application.Dtos.CarModels;
+using CarRental.Application.Services;
+
+namespace CarRental.Api.Controllers;
+
+///
+/// Controller for managing car models in the car rental system.
+///
+public class CarModelController(
+ ICrudService service
+) : CrudControllerBase(service);
\ No newline at end of file
diff --git a/CarRental/CarRental.Api/Controllers/CrudControllerBase.cs b/CarRental/CarRental.Api/Controllers/CrudControllerBase.cs
new file mode 100644
index 000000000..820f648f5
--- /dev/null
+++ b/CarRental/CarRental.Api/Controllers/CrudControllerBase.cs
@@ -0,0 +1,123 @@
+using CarRental.Application.Services;
+using Microsoft.AspNetCore.Mvc;
+namespace CarRental.Api.Controllers;
+
+///
+/// Base controller providing CRUD operations for entities.
+///
+/// The response DTO type
+/// The create DTO type
+/// The update DTO type
+[ApiController]
+[Produces("application/json")]
+[Route("api/[controller]")]
+public abstract class CrudControllerBase
+ (ICrudService service) : ControllerBase
+{
+
+ ///
+ /// Retrieves all entities.
+ ///
+ /// A list of all entities
+ [HttpGet]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status500InternalServerError)]
+ public virtual async Task>> GetAll()
+ {
+ var entities = await service.GetAsync();
+ return Ok(entities);
+ }
+
+ ///
+ /// Retrieves a specific entity by its unique identifier.
+ ///
+ /// The ID of the entity to retrieve
+ /// The entity record if found
+ [HttpGet("{id:int}")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status500InternalServerError)]
+ public virtual async Task> GetById(int id)
+ {
+ var entity = await service.GetAsync(id);
+ if (entity == null)
+ return NotFound();
+
+ return Ok(entity);
+ }
+
+ ///
+ /// Creates a new entity record.
+ ///
+ /// The entity data to create
+ /// The newly created entity record
+ [HttpPost]
+ [ProducesResponseType(StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status500InternalServerError)]
+ public virtual async Task> Create([FromBody] TCreateDto createDto)
+ {
+ if (!ModelState.IsValid)
+ return BadRequest(ModelState);
+
+ var entity = await service.CreateAsync(createDto);
+ var id = GetIdFromDto(entity);
+
+ return CreatedAtAction(nameof(GetById), new { id }, entity);
+ }
+
+ ///
+ /// Updates an existing entity record.
+ ///
+ /// The ID of the entity to update
+ /// The updated entity data
+ /// No content if successful
+ [HttpPut("{id:int}")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status500InternalServerError)]
+ public virtual async Task Update(int id, [FromBody] TUpdateDto updateDto)
+ {
+ if (!ModelState.IsValid)
+ return BadRequest(ModelState);
+
+ var updatedEntity = await service.UpdateAsync(id, updateDto);
+ if (updatedEntity == null)
+ return NotFound();
+
+ return NoContent();
+ }
+
+ ///
+ /// Deletes an entity record.
+ ///
+ /// The ID of the entity to delete
+ /// No content if successful
+ [HttpDelete("{id:int}")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status500InternalServerError)]
+ public virtual async Task Delete(int id)
+ {
+ var deleted = await service.DeleteAsync(id);
+ return NoContent();
+ }
+
+ ///
+ /// Gets the ID from the DTO using reflection.
+ ///
+ private static int GetIdFromDto(TDto dto)
+ {
+ var property = dto?.GetType().GetProperty("Id")
+ ?? throw new InvalidOperationException($"DTO type {typeof(TDto).Name} does not have an 'Id' property.");
+
+ if (property.PropertyType != typeof(int))
+ throw new InvalidOperationException($"Id property in {typeof(TDto).Name} must be of type int.");
+
+ var value = property.GetValue(dto);
+ if (value is not int id)
+ throw new InvalidOperationException($"Failed to retrieve Id from {typeof(TDto).Name}.");
+
+ return id;
+ }
+}
\ No newline at end of file
diff --git a/CarRental/CarRental.Api/Controllers/CustomerController.cs b/CarRental/CarRental.Api/Controllers/CustomerController.cs
new file mode 100644
index 000000000..63e6e13f0
--- /dev/null
+++ b/CarRental/CarRental.Api/Controllers/CustomerController.cs
@@ -0,0 +1,11 @@
+using CarRental.Application.Dtos.Customers;
+using CarRental.Application.Services;
+
+namespace CarRental.Api.Controllers;
+
+///
+/// Controller for managing customers in the car rental system.
+///
+public class CustomerController(
+ ICrudService service
+) : CrudControllerBase(service);
\ No newline at end of file
diff --git a/CarRental/CarRental.Api/Controllers/ModelGenerationController.cs b/CarRental/CarRental.Api/Controllers/ModelGenerationController.cs
new file mode 100644
index 000000000..3efe861d1
--- /dev/null
+++ b/CarRental/CarRental.Api/Controllers/ModelGenerationController.cs
@@ -0,0 +1,41 @@
+using CarRental.Application.Dtos.CarModels;
+using CarRental.Application.Dtos.ModelGenerations;
+using CarRental.Application.Services;
+using Microsoft.AspNetCore.Mvc;
+
+namespace CarRental.Api.Controllers;
+
+///
+/// Controller for managing model generations in the car rental system.
+///
+public class ModelGenerationController(
+ ICrudService service,
+ ICrudService carModelService
+) : CrudControllerBase(service)
+{
+ public override async Task> Create([FromBody] ModelGenerationCreateDto createDto)
+ {
+ if (!ModelState.IsValid) return BadRequest(ModelState);
+
+ var carModel = await carModelService.GetAsync(createDto.CarModelId);
+ if (carModel == null)
+ return BadRequest(new { error = $"Car model with ID {createDto.CarModelId} does not exist" });
+
+ var entity = await service.CreateAsync(createDto);
+ return CreatedAtAction(nameof(GetById), new { id = entity.Id }, entity);
+ }
+
+ public override async Task Update(int id, [FromBody] ModelGenerationUpdateDto updateDto)
+ {
+ if (!ModelState.IsValid) return BadRequest(ModelState);
+
+ var carModel = await carModelService.GetAsync(updateDto.CarModelId);
+ if (carModel == null)
+ return BadRequest(new { error = $"Car model with ID {updateDto.CarModelId} does not exist" });
+
+ var updated = await service.UpdateAsync(id, updateDto);
+ if (updated == null) return NotFound();
+
+ return NoContent();
+ }
+}
\ No newline at end of file
diff --git a/CarRental/CarRental.Api/Controllers/RentalController.cs b/CarRental/CarRental.Api/Controllers/RentalController.cs
new file mode 100644
index 000000000..eb0fb1356
--- /dev/null
+++ b/CarRental/CarRental.Api/Controllers/RentalController.cs
@@ -0,0 +1,99 @@
+using CarRental.Application.Dtos.Cars;
+using CarRental.Application.Dtos.Customers;
+using CarRental.Application.Dtos.Rentals;
+using CarRental.Application.Services;
+using Microsoft.AspNetCore.Mvc;
+
+namespace CarRental.Api.Controllers;
+
+///
+/// Controller for managing rentals in the car rental system.
+///
+public class RentalsController
+ (
+ ICrudService service,
+ ICrudService customerService,
+ ICrudService carService
+ )
+ : CrudControllerBase(service)
+{
+ ///
+ /// Creates a new rental agreement.
+ ///
+ /// The rental data to create
+ /// The newly created rental record
+ /// Returns the newly created rental
+ /// If the request data is invalid or customer/car does not exist
+ /// If there was an internal server error
+ public override async Task> Create([FromBody] RentalCreateDto createDto)
+ {
+ try
+ {
+ if (!ModelState.IsValid) return BadRequest(ModelState);
+
+ var customer = await customerService.GetAsync(createDto.CustomerId);
+ if (customer == null)
+ {
+ return BadRequest(new { error = $"Customer with ID {createDto.CustomerId} does not exist" });
+ }
+
+ var car = await carService.GetAsync(createDto.CarId);
+ if (car == null)
+ {
+ return BadRequest(new { error = $"Car with ID {createDto.CarId} does not exist" });
+ }
+
+ var entity = await service.CreateAsync(createDto);
+ return CreatedAtAction(nameof(GetById), new { id = entity.Id }, entity);
+ }
+ catch (InvalidOperationException)
+ {
+ return StatusCode(500);
+ }
+ }
+
+ ///
+ /// Updates an existing rental agreement.
+ ///
+ /// The ID of the rental to update
+ /// The updated rental data
+ /// No content if successful
+ /// If the update was successful
+ /// If the request data is invalid or customer/car does not exist
+ /// If the rental with the specified ID was not found
+ /// If there was an internal server error
+ public override async Task Update(int id, [FromBody] RentalUpdateDto updateDto)
+ {
+ try
+ {
+ if (!ModelState.IsValid) return BadRequest(ModelState);
+
+ if (updateDto.CustomerId != default)
+ {
+ var customer = await customerService.GetAsync(updateDto.CustomerId);
+ if (customer == null)
+ {
+ return BadRequest(new { error = $"Customer with ID {updateDto.CustomerId} does not exist" });
+ }
+ }
+
+ if (updateDto.CarId != default)
+ {
+ var car = await carService.GetAsync(updateDto.CarId);
+ if (car == null)
+ {
+ return BadRequest(new { error = $"Car with ID {updateDto.CarId} does not exist" });
+ }
+ }
+
+ var updatedEntity = await service.UpdateAsync(id, updateDto);
+ if (updatedEntity == null) return NotFound();
+
+ return NoContent();
+ }
+ catch (InvalidOperationException)
+ {
+ return StatusCode(500);
+ }
+ }
+}
\ No newline at end of file
diff --git a/CarRental/CarRental.Api/Middleware/LoggingMiddleware.cs b/CarRental/CarRental.Api/Middleware/LoggingMiddleware.cs
new file mode 100644
index 000000000..2a95513e7
--- /dev/null
+++ b/CarRental/CarRental.Api/Middleware/LoggingMiddleware.cs
@@ -0,0 +1,40 @@
+using System.Diagnostics;
+namespace CarRental.Api.Middleware;
+
+///
+/// Middleware for logging HTTP requests and responses.
+/// Logs request details, execution time, and any exceptions that occur during request processing.
+///
+public class LoggingMiddleware(RequestDelegate next, ILogger logger)
+{
+ ///
+ /// Processes an HTTP request and logs details about the request, response, and execution time.
+ ///
+ /// The HTTP context for the current request.
+ /// A task that represents the completion of request processing.
+ public async Task InvokeAsync(HttpContext context)
+ {
+ var requestId = Guid.NewGuid().ToString("N")[..8];
+ var stopwatch = Stopwatch.StartNew();
+
+ try
+ {
+ logger.LogInformation("REQUEST_START | ID:{RequestId} | Method:{Method} | Path:{Path} | RemoteIP:{RemoteIp}",
+ requestId, context.Request.Method, context.Request.Path, context.Connection.RemoteIpAddress);
+
+ await next(context);
+
+ stopwatch.Stop();
+
+ logger.LogInformation("REQUEST_END | ID:{RequestId} | Method:{Method} | Path:{Path} | Status:{StatusCode} | Time:{ElapsedMs}ms",
+ requestId, context.Request.Method, context.Request.Path, context.Response.StatusCode, stopwatch.ElapsedMilliseconds);
+ }
+ catch (Exception ex)
+ {
+ stopwatch.Stop();
+ logger.LogError(ex, "REQUEST_ERROR | ID:{RequestId} | Method:{Method} | Path:{Path} | Status:500 | Time:{ElapsedMs}ms | Error:{ErrorMessage}",
+ requestId, context.Request.Method, context.Request.Path, stopwatch.ElapsedMilliseconds, ex.Message);
+ throw;
+ }
+ }
+}
\ No newline at end of file
diff --git a/CarRental/CarRental.Api/Program.cs b/CarRental/CarRental.Api/Program.cs
new file mode 100644
index 000000000..f5ed8abc8
--- /dev/null
+++ b/CarRental/CarRental.Api/Program.cs
@@ -0,0 +1,119 @@
+using CarRental.Application.Dtos.CarModels;
+using CarRental.Application.Dtos.Cars;
+using CarRental.Application.Dtos.Customers;
+using CarRental.Application.Dtos.ModelGenerations;
+using CarRental.Application.Dtos.Rentals;
+using CarRental.Application.Profiles;
+using CarRental.Application.Services;
+using CarRental.Infrastructure.Data;
+using CarRental.Infrastructure.Data.Interfaces;
+using CarRental.Infrastructure.Persistence;
+using CarRental.Infrastructure.Repositories;
+using CarRental.Infrastructure.Repositories.Interfaces;
+using CarRental.ServiceDefaults;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.OpenApi.Models;
+using System.Text.Json.Serialization;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.AddServiceDefaults();
+
+builder.AddSqlServerDbContext("carrentaldb", configureDbContextOptions: opt =>
+{
+ opt.UseLazyLoadingProxies();
+});
+
+builder.Services.Configure(options =>
+{
+ options.LowercaseUrls = true;
+ options.LowercaseQueryStrings = true;
+});
+
+builder.Services.AddControllers().AddJsonOptions(opts =>
+{
+ opts.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
+});
+
+builder.Services.AddAuthorization();
+builder.Services.AddEndpointsApiExplorer();
+
+builder.Services.AddAutoMapper(config =>
+{
+ config.AddProfile(new MappingProfile());
+});
+
+builder.Services.AddSingleton();
+builder.Services.AddScoped();
+
+builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
+
+builder.Services.AddScoped, CarService>();
+builder.Services.AddScoped, CustomerService>();
+builder.Services.AddScoped, CarModelService>();
+builder.Services.AddScoped, ModelGenerationService>();
+builder.Services.AddScoped, RentalService>();
+
+builder.Services.AddScoped();
+
+builder.Services.AddSwaggerGen(c =>
+{
+ c.SwaggerDoc("v1", new OpenApiInfo
+ {
+ Title = "Car Rental API",
+ Version = "v1",
+ Description = "Car Rental Management System API"
+ });
+
+ var basePath = AppContext.BaseDirectory;
+ c.IncludeXmlComments(Path.Combine(basePath, "CarRental.Api.xml"));
+ c.IncludeXmlComments(Path.Combine(basePath, "CarRental.Application.xml"));
+ c.IncludeXmlComments(Path.Combine(basePath, "CarRental.Domain.xml"));
+});
+
+var app = builder.Build();
+
+app.MapDefaultEndpoints();
+
+if (app.Environment.IsDevelopment())
+{
+ app.UseSwagger();
+ app.UseSwaggerUI();
+}
+
+using var migrationScope = app.Services.CreateScope();
+var context = migrationScope.ServiceProvider.GetRequiredService();
+var logger = migrationScope.ServiceProvider.GetRequiredService>();
+
+try
+{
+ logger.LogInformation("Applying database migrations...");
+ await context.Database.MigrateAsync();
+ logger.LogInformation("Migrations applied successfully");
+}
+catch (Exception ex)
+{
+ logger.LogError(ex, "An error occurred during database migrations");
+ throw;
+}
+
+using var seedingScope = app.Services.CreateScope();
+var seeder = seedingScope.ServiceProvider.GetRequiredService();
+var seedingLogger = seedingScope.ServiceProvider.GetRequiredService>();
+
+try
+{
+ seedingLogger.LogInformation("Seeding database...");
+ await seeder.SeedAsync();
+ seedingLogger.LogInformation("Database seeded successfully");
+}
+catch (Exception ex)
+{
+ seedingLogger.LogError(ex, "An error occurred during database seeding");
+}
+
+app.UseHttpsRedirection();
+app.UseAuthorization();
+app.MapControllers();
+
+app.Run();
\ No newline at end of file
diff --git a/CarRental/CarRental.Api/Properties/launchSettings.json b/CarRental/CarRental.Api/Properties/launchSettings.json
new file mode 100644
index 000000000..860e60bdf
--- /dev/null
+++ b/CarRental/CarRental.Api/Properties/launchSettings.json
@@ -0,0 +1,41 @@
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "iisSettings": {
+ "windowsAuthentication": false,
+ "anonymousAuthentication": true,
+ "iisExpress": {
+ "applicationUrl": "http://localhost:8879",
+ "sslPort": 44371
+ }
+ },
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "launchUrl": "swagger",
+ "applicationUrl": "http://localhost:5085",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "launchUrl": "swagger",
+ "applicationUrl": "https://localhost:7074;http://localhost:5085",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "IIS Express": {
+ "commandName": "IISExpress",
+ "launchBrowser": true,
+ "launchUrl": "swagger",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/CarRental/CarRental.Api/appsettings.Development.json b/CarRental/CarRental.Api/appsettings.Development.json
new file mode 100644
index 000000000..0c208ae91
--- /dev/null
+++ b/CarRental/CarRental.Api/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/CarRental/CarRental.Api/appsettings.json b/CarRental/CarRental.Api/appsettings.json
new file mode 100644
index 000000000..ce27fa65a
--- /dev/null
+++ b/CarRental/CarRental.Api/appsettings.json
@@ -0,0 +1,11 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Information",
+ "Microsoft.AspNetCore.Hosting": "Information",
+ "Microsoft.AspNetCore.Routing": "Information",
+ "Clinic.Application.Middleware.LoggingMiddleware": "Information"
+ }
+ }
+}
\ No newline at end of file
diff --git a/CarRental/CarRental.AppHost/AppHost.cs b/CarRental/CarRental.AppHost/AppHost.cs
new file mode 100644
index 000000000..7c37c3db6
--- /dev/null
+++ b/CarRental/CarRental.AppHost/AppHost.cs
@@ -0,0 +1,21 @@
+var builder = DistributedApplication.CreateBuilder(args);
+
+var password = builder.AddParameter("DatabasePassword", secret: true);
+
+var carrentalDb = builder.AddSqlServer("sqlserver", password: password)
+ .AddDatabase("carrentaldb");
+
+builder.AddProject("carrental-api")
+ .WithReference(carrentalDb)
+ .WithExternalHttpEndpoints()
+ .WaitFor(carrentalDb);
+
+var consumer = builder.AddProject("grpc-consumer")
+ .WithReference(carrentalDb)
+ .WaitFor(carrentalDb);
+
+builder.AddProject("grpc-producer")
+ .WaitFor(carrentalDb)
+ .WaitFor(consumer);
+
+builder.Build().Run();
\ No newline at end of file
diff --git a/CarRental/CarRental.AppHost/CarRental.AppHost.csproj b/CarRental/CarRental.AppHost/CarRental.AppHost.csproj
new file mode 100644
index 000000000..54cfb5ef5
--- /dev/null
+++ b/CarRental/CarRental.AppHost/CarRental.AppHost.csproj
@@ -0,0 +1,25 @@
+
+
+
+
+
+ Exe
+ net8.0
+ enable
+ enable
+ true
+ 70dd5e9d-2669-455c-87c8-a11d69e37eff
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/CarRental/CarRental.AppHost/Properties/launchSettings.json b/CarRental/CarRental.AppHost/Properties/launchSettings.json
new file mode 100644
index 000000000..9cbe8250e
--- /dev/null
+++ b/CarRental/CarRental.AppHost/Properties/launchSettings.json
@@ -0,0 +1,33 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:17241;http://localhost:15153",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21040",
+ "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22095",
+ "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21040",
+ "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true"
+ }
+ },
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:15153",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19161",
+ "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20064",
+ "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21040",
+ "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/CarRental/CarRental.AppHost/appsettings.Development.json b/CarRental/CarRental.AppHost/appsettings.Development.json
new file mode 100644
index 000000000..0c208ae91
--- /dev/null
+++ b/CarRental/CarRental.AppHost/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/CarRental/CarRental.AppHost/appsettings.json b/CarRental/CarRental.AppHost/appsettings.json
new file mode 100644
index 000000000..840be2fe3
--- /dev/null
+++ b/CarRental/CarRental.AppHost/appsettings.json
@@ -0,0 +1,6 @@
+{
+
+ "Parameters": {
+ "DatabasePassword": "MyStr0ngP@ssw0rd2026"
+ }
+}
\ No newline at end of file
diff --git a/CarRental/CarRental.Application/CarRental.Application.csproj b/CarRental/CarRental.Application/CarRental.Application.csproj
new file mode 100644
index 000000000..7ea792cb4
--- /dev/null
+++ b/CarRental/CarRental.Application/CarRental.Application.csproj
@@ -0,0 +1,31 @@
+
+
+
+ net8.0
+ enable
+ enable
+ True
+ $(NoWarn);1591
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/CarRental/CarRental.Application/Dtos/CarModels/CarModelCreateDto.cs b/CarRental/CarRental.Application/Dtos/CarModels/CarModelCreateDto.cs
new file mode 100644
index 000000000..89fdebcb4
--- /dev/null
+++ b/CarRental/CarRental.Application/Dtos/CarModels/CarModelCreateDto.cs
@@ -0,0 +1,45 @@
+using CarRental.Application.Validation;
+using CarRental.Domain.Enums;
+using System.ComponentModel.DataAnnotations;
+
+namespace CarRental.Application.Dtos.CarModels;
+///
+/// DTO for creating a new car model.
+///
+public class CarModelCreateDto
+{
+ ///
+ /// Name of the car model (e.g. Toyota Camry, BMW X5).
+ ///
+ [Required(ErrorMessage = "Model name is required")]
+ [StringLength(100, MinimumLength = 2, ErrorMessage = "Model name must be between 2 and 100 characters")]
+ public required string Name { get; set; }
+
+ ///
+ /// Drivetrain type.
+ ///
+ [Required(ErrorMessage = "Drivetrain type is required")]
+ [EnumRange(typeof(DriverType))]
+ public required DriverType DriverType { get; set; }
+
+ ///
+ /// Number of seats (including driver).
+ ///
+ [Required(ErrorMessage = "Seating capacity is required")]
+ [Range(2, 20, ErrorMessage = "Seating capacity must be between 2 and 20")]
+ public required byte SeatingCapacity { get; set; }
+
+ ///
+ /// Body type / style.
+ ///
+ [Required(ErrorMessage = "Body type is required")]
+ [EnumRange(typeof(BodyType))]
+ public required BodyType BodyType { get; set; }
+
+ ///
+ /// Vehicle class / segment.
+ ///
+ [Required(ErrorMessage = "Car class is required")]
+ [EnumRange(typeof(CarClass))]
+ public required CarClass CarClass { get; set; }
+}
\ No newline at end of file
diff --git a/CarRental/CarRental.Application/Dtos/CarModels/CarModelResponseDto.cs b/CarRental/CarRental.Application/Dtos/CarModels/CarModelResponseDto.cs
new file mode 100644
index 000000000..b5c83f85d
--- /dev/null
+++ b/CarRental/CarRental.Application/Dtos/CarModels/CarModelResponseDto.cs
@@ -0,0 +1,8 @@
+using CarRental.Domain.Models;
+
+namespace CarRental.Application.Dtos.CarModels;
+
+///
+/// DTO for returning car model information.
+///
+public class CarModelResponseDto: CarModel;
diff --git a/CarRental/CarRental.Application/Dtos/CarModels/CarModelUpdate.cs b/CarRental/CarRental.Application/Dtos/CarModels/CarModelUpdate.cs
new file mode 100644
index 000000000..637a91bf9
--- /dev/null
+++ b/CarRental/CarRental.Application/Dtos/CarModels/CarModelUpdate.cs
@@ -0,0 +1,6 @@
+namespace CarRental.Application.Dtos.CarModels;
+
+///
+/// DTO for updating an existing car model.
+///
+public class CarModelUpdateDto : CarModelCreateDto;
\ No newline at end of file
diff --git a/CarRental/CarRental.Application/Dtos/Cars/CarCreateDto.cs b/CarRental/CarRental.Application/Dtos/Cars/CarCreateDto.cs
new file mode 100644
index 000000000..604c474b5
--- /dev/null
+++ b/CarRental/CarRental.Application/Dtos/Cars/CarCreateDto.cs
@@ -0,0 +1,31 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace CarRental.Application.Dtos.Cars;
+///
+/// DTO for creating a new car.
+///
+public class CarCreateDto
+{
+ ///
+ /// License plate number (e.g. A123BC 777).
+ ///
+ [Required(ErrorMessage = "License plate is required")]
+ [StringLength(12, MinimumLength = 6, ErrorMessage = "License plate must be between 6 and 12 characters")]
+ [RegularExpression(@"^[АВЕКМНОРСТУХ]\d{3}[АВЕКМНОРСТУХ]{2}\s\d{3}$",
+ ErrorMessage = "License plate format example: A123BC 777")]
+ public required string LicensePlate { get; set; }
+
+ ///
+ /// Color of the vehicle.
+ ///
+ [Required(ErrorMessage = "Color is required")]
+ [StringLength(50, MinimumLength = 3, ErrorMessage = "Color must be between 3 and 50 characters")]
+ public required string Color { get; set; }
+
+ ///
+ /// ID of the model generation this car belongs to.
+ ///
+ [Required(ErrorMessage = "Model generation ID is required")]
+ [Range(1, int.MaxValue, ErrorMessage = "Invalid model generation ID")]
+ public required int ModelGenerationId { get; set; }
+}
\ No newline at end of file
diff --git a/CarRental/CarRental.Application/Dtos/Cars/CarResponseDto.cs b/CarRental/CarRental.Application/Dtos/Cars/CarResponseDto.cs
new file mode 100644
index 000000000..d84d3053d
--- /dev/null
+++ b/CarRental/CarRental.Application/Dtos/Cars/CarResponseDto.cs
@@ -0,0 +1,6 @@
+using CarRental.Domain.Models;
+namespace CarRental.Application.Dtos.Cars;
+///
+/// DTO for returning car information.
+///
+public class CarResponseDto: Car;
diff --git a/CarRental/CarRental.Application/Dtos/Cars/CarUpdateDto.cs b/CarRental/CarRental.Application/Dtos/Cars/CarUpdateDto.cs
new file mode 100644
index 000000000..4c7535656
--- /dev/null
+++ b/CarRental/CarRental.Application/Dtos/Cars/CarUpdateDto.cs
@@ -0,0 +1,6 @@
+namespace CarRental.Application.Dtos.Cars;
+
+///
+/// DTO for updating an existing car.
+///
+public class CarUpdateDto : CarCreateDto;
\ No newline at end of file
diff --git a/CarRental/CarRental.Application/Dtos/Customers/CustomerCreateDto.cs b/CarRental/CarRental.Application/Dtos/Customers/CustomerCreateDto.cs
new file mode 100644
index 000000000..99bcf686b
--- /dev/null
+++ b/CarRental/CarRental.Application/Dtos/Customers/CustomerCreateDto.cs
@@ -0,0 +1,29 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace CarRental.Application.Dtos.Customers;
+
+///
+/// DTO for creating a new customer.
+///
+public class CustomerCreateDto
+{
+ ///
+ /// Driver's license number (unique identifier).
+ ///
+ [Required(ErrorMessage = "Driver's license number is required")]
+ [StringLength(20, MinimumLength = 8, ErrorMessage = "Driver's license must be between 8 and 20 characters")]
+ public required string DriverLicenseNumber { get; set; }
+
+ ///
+ /// Full name of the customer.
+ ///
+ [Required(ErrorMessage = "Full name is required")]
+ [StringLength(150, MinimumLength = 3, ErrorMessage = "Full name must be between 3 and 150 characters")]
+ public required string FullName { get; set; }
+
+ ///
+ /// Date of birth.
+ ///
+ [Required(ErrorMessage = "Date of birth is required")]
+ public required DateOnly DateOfBirth { get; set; }
+}
\ No newline at end of file
diff --git a/CarRental/CarRental.Application/Dtos/Customers/CustomerResponseDto.cs b/CarRental/CarRental.Application/Dtos/Customers/CustomerResponseDto.cs
new file mode 100644
index 000000000..97be2493b
--- /dev/null
+++ b/CarRental/CarRental.Application/Dtos/Customers/CustomerResponseDto.cs
@@ -0,0 +1,8 @@
+using CarRental.Domain.Models;
+
+namespace CarRental.Application.Dtos.Customers;
+
+///
+/// DTO for returning customer information.
+///
+public class CustomerResponseDto : Customer;
\ No newline at end of file
diff --git a/CarRental/CarRental.Application/Dtos/Customers/CustomerUpdateDto.cs b/CarRental/CarRental.Application/Dtos/Customers/CustomerUpdateDto.cs
new file mode 100644
index 000000000..fd4271f18
--- /dev/null
+++ b/CarRental/CarRental.Application/Dtos/Customers/CustomerUpdateDto.cs
@@ -0,0 +1,5 @@
+namespace CarRental.Application.Dtos.Customers;
+///
+/// DTO for updating an existing customer.
+///
+public class CustomerUpdateDto : CustomerCreateDto;
diff --git a/CarRental/CarRental.Application/Dtos/ModelGenerations/ModelGenerationCreateDto.cs b/CarRental/CarRental.Application/Dtos/ModelGenerations/ModelGenerationCreateDto.cs
new file mode 100644
index 000000000..e1cada0e1
--- /dev/null
+++ b/CarRental/CarRental.Application/Dtos/ModelGenerations/ModelGenerationCreateDto.cs
@@ -0,0 +1,46 @@
+using CarRental.Application.Validation;
+using CarRental.Domain.Enums;
+using System.ComponentModel.DataAnnotations;
+
+namespace CarRental.Application.Dtos.ModelGenerations;
+
+///
+/// DTO for creating a new model generation.
+///
+public class ModelGenerationCreateDto
+{
+ ///
+ /// Year when this generation started production.
+ ///
+ [Required(ErrorMessage = "Production year is required")]
+ [Range(1900, 2100, ErrorMessage = "Production year must be between 1900 and 2100")]
+ public required int ProductionYear { get; set; }
+
+ ///
+ /// Engine displacement in liters.
+ ///
+ [Required(ErrorMessage = "Engine volume is required")]
+ [Range(0.1, 10.0, ErrorMessage = "Engine volume must be between 0.1 and 10.0 liters")]
+ public required decimal EngineVolumeLiters { get; set; }
+
+ ///
+ /// Type of transmission.
+ ///
+ [Required(ErrorMessage = "Transmission type is required")]
+ [EnumRange(typeof(TransmissionType))]
+ public required TransmissionType TransmissionType { get; set; }
+
+ ///
+ /// Hourly rental rate for this generation.
+ ///
+ [Required(ErrorMessage = "Hourly rate is required")]
+ [Range(100, 100000, ErrorMessage = "Hourly rate must be between 100 and 100000")]
+ public required decimal HourlyRate { get; set; }
+
+ ///
+ /// ID of the base car model.
+ ///
+ [Required(ErrorMessage = "Car model ID is required")]
+ [Range(1, int.MaxValue)]
+ public required int CarModelId { get; set; }
+}
\ No newline at end of file
diff --git a/CarRental/CarRental.Application/Dtos/ModelGenerations/ModelGenerationResponseDto.cs b/CarRental/CarRental.Application/Dtos/ModelGenerations/ModelGenerationResponseDto.cs
new file mode 100644
index 000000000..87f7339e7
--- /dev/null
+++ b/CarRental/CarRental.Application/Dtos/ModelGenerations/ModelGenerationResponseDto.cs
@@ -0,0 +1,8 @@
+using CarRental.Domain.Models;
+
+namespace CarRental.Application.Dtos.ModelGenerations;
+
+///
+/// DTO for returning model generation information.
+///
+public class ModelGenerationResponseDto: ModelGeneration;
\ No newline at end of file
diff --git a/CarRental/CarRental.Application/Dtos/ModelGenerations/ModelGenerationUpdateDto.cs b/CarRental/CarRental.Application/Dtos/ModelGenerations/ModelGenerationUpdateDto.cs
new file mode 100644
index 000000000..6f7edbbb2
--- /dev/null
+++ b/CarRental/CarRental.Application/Dtos/ModelGenerations/ModelGenerationUpdateDto.cs
@@ -0,0 +1,6 @@
+namespace CarRental.Application.Dtos.ModelGenerations;
+
+///
+/// DTO for updating an existing model generation.
+///
+public class ModelGenerationUpdateDto : ModelGenerationCreateDto;
diff --git a/CarRental/CarRental.Application/Dtos/Rentals/RentalCreateDto.cs b/CarRental/CarRental.Application/Dtos/Rentals/RentalCreateDto.cs
new file mode 100644
index 000000000..419895bb5
--- /dev/null
+++ b/CarRental/CarRental.Application/Dtos/Rentals/RentalCreateDto.cs
@@ -0,0 +1,35 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace CarRental.Application.Dtos.Rentals;
+///
+/// DTO for creating a new rental agreement.
+///
+public class RentalCreateDto
+{
+ ///
+ /// ID of the customer renting the car.
+ ///
+ [Required(ErrorMessage = "Customer ID is required")]
+ [Range(1, int.MaxValue)]
+ public required int CustomerId { get; set; }
+
+ ///
+ /// ID of the car being rented.
+ ///
+ [Required(ErrorMessage = "Car ID is required")]
+ [Range(1, int.MaxValue)]
+ public required int CarId { get; set; }
+
+ ///
+ /// Date and time when the car is picked up.
+ ///
+ [Required(ErrorMessage = "Pickup date and time is required")]
+ public required DateTime PickupDateTime { get; set; }
+
+ ///
+ /// Rental duration in hours.
+ ///
+ [Required(ErrorMessage = "Rental duration is required")]
+ [Range(1, 8760, ErrorMessage = "Duration must be between 1 hour and 1 year (8760 hours)")]
+ public required int Hours { get; set; }
+}
\ No newline at end of file
diff --git a/CarRental/CarRental.Application/Dtos/Rentals/RentalResponseDto.cs b/CarRental/CarRental.Application/Dtos/Rentals/RentalResponseDto.cs
new file mode 100644
index 000000000..40fdd2672
--- /dev/null
+++ b/CarRental/CarRental.Application/Dtos/Rentals/RentalResponseDto.cs
@@ -0,0 +1,7 @@
+using CarRental.Domain.Models;
+
+namespace CarRental.Application.Dtos.Rentals;
+///
+/// DTO for returning rental information.
+///
+public class RentalResponseDto : Rental;
diff --git a/CarRental/CarRental.Application/Dtos/Rentals/RentalUpdateDto.cs b/CarRental/CarRental.Application/Dtos/Rentals/RentalUpdateDto.cs
new file mode 100644
index 000000000..cce81bae6
--- /dev/null
+++ b/CarRental/CarRental.Application/Dtos/Rentals/RentalUpdateDto.cs
@@ -0,0 +1,5 @@
+namespace CarRental.Application.Dtos.Rentals;
+///
+/// DTO for updating a rental (e.g. extend duration).
+///
+public class RentalUpdateDto: RentalCreateDto;
diff --git a/CarRental/CarRental.Application/Profiles/MappingProfile.cs b/CarRental/CarRental.Application/Profiles/MappingProfile.cs
new file mode 100644
index 000000000..bed7f3a79
--- /dev/null
+++ b/CarRental/CarRental.Application/Profiles/MappingProfile.cs
@@ -0,0 +1,54 @@
+using AutoMapper;
+using CarRental.Domain.Models;
+using CarRental.Application.Dtos.Cars;
+using CarRental.Application.Dtos.Customers;
+using CarRental.Application.Dtos.CarModels;
+using CarRental.Application.Dtos.ModelGenerations;
+using CarRental.Application.Dtos.Rentals;
+
+namespace CarRental.Application.Profiles;
+
+///
+/// AutoMapper profile configuration for mapping between domain models and DTOs.
+/// Defines all object-to-object mappings used in the application.
+///
+public class MappingProfile : Profile
+{
+ ///
+ /// Initializes a new instance of the MappingProfile class and configures all mappings.
+ ///
+ public MappingProfile()
+ {
+ CreateMap();
+ CreateMap();
+ CreateMap();
+ CreateMap();
+ CreateMap();
+
+ CreateMap();
+ CreateMap()
+ .ForMember(dest => dest.DriverLicenseNumber,
+ opt => opt.MapFrom(src => NormalizeDriverLicense(src.DriverLicenseNumber)));
+ CreateMap();
+ CreateMap();
+ CreateMap();
+
+ CreateMap();
+ CreateMap()
+ .ForMember(dest => dest.DriverLicenseNumber,
+ opt => opt.MapFrom(src => NormalizeDriverLicense(src.DriverLicenseNumber)));
+ CreateMap();
+ CreateMap();
+ CreateMap()
+ .ForAllMembers(opt => opt.Condition((src, dest, srcMember) => srcMember != null));
+ }
+
+ private static string? NormalizeDriverLicense(string? license)
+ {
+ if (string.IsNullOrWhiteSpace(license)) return null;
+
+ var cleaned = new string(license.Where(c => char.IsLetterOrDigit(c)).ToArray());
+
+ return cleaned.ToUpperInvariant();
+ }
+}
\ No newline at end of file
diff --git a/CarRental/CarRental.Application/Protos/rental_streaming.proto b/CarRental/CarRental.Application/Protos/rental_streaming.proto
new file mode 100644
index 000000000..0d96f3c08
--- /dev/null
+++ b/CarRental/CarRental.Application/Protos/rental_streaming.proto
@@ -0,0 +1,25 @@
+syntax = "proto3";
+
+option csharp_namespace = "CarRental.Application.Dtos.Grpc";
+
+import "google/protobuf/timestamp.proto";
+
+package carrental;
+
+service RentalStreaming {
+ rpc StreamRentals (stream RentalRequestMessage) returns (stream RentalResponseMessage);
+}
+
+message RentalRequestMessage {
+ int32 customer_id = 1;
+ int32 car_id = 2;
+ google.protobuf.Timestamp pickup_date_time = 3;
+ int32 hours = 4;
+}
+
+message RentalResponseMessage {
+ bool success = 1;
+ string message = 2;
+ int64 rental_id = 3;
+ string error_code = 4;
+}
\ No newline at end of file
diff --git a/CarRental/CarRental.Application/Services/AnalyticQueryService.cs b/CarRental/CarRental.Application/Services/AnalyticQueryService.cs
new file mode 100644
index 000000000..a073f1a0a
--- /dev/null
+++ b/CarRental/CarRental.Application/Services/AnalyticQueryService.cs
@@ -0,0 +1,182 @@
+using AutoMapper;
+using CarRental.Application.Dtos.Cars;
+using CarRental.Application.Dtos.Customers;
+using CarRental.Domain.Models;
+using CarRental.Infrastructure.Repositories.Interfaces;
+
+namespace CarRental.Application.Services;
+
+///
+/// Implementation of car rental analytic query service.
+///
+public class AnalyticQueryService(
+ IRepository carRepository,
+ IRepository customerRepository,
+ IRepository rentalRepository,
+ IRepository modelGenerationRepository,
+ IMapper mapper
+) : IAnalyticQueryService
+{
+ ///
+ public async Task> GetTop5MostRentedCarsAsync()
+ {
+ try
+ {
+ var rentals = await rentalRepository.GetAsync();
+ var cars = await carRepository.GetAsync();
+
+ var topCars = rentals
+ .GroupBy(r => r.CarId)
+ .Select(g => new
+ {
+ CarId = g.Key,
+ Count = g.Count()
+ })
+ .OrderByDescending(x => x.Count)
+ .ThenBy(x => cars.First(c => c.Id == x.CarId).LicensePlate)
+ .Take(5)
+ .Join(cars,
+ x => x.CarId,
+ c => c.Id,
+ (x, c) => c)
+ .ToList();
+
+ return mapper.Map>(topCars);
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException("Failed to retrieve top 5 most rented cars", ex);
+ }
+ }
+
+ ///
+ public async Task> GetRentalCountForEachCarAsync()
+ {
+ try
+ {
+ var rentals = await rentalRepository.GetAsync();
+ var cars = await carRepository.GetAsync();
+
+ var counts = rentals
+ .GroupBy(r => r.CarId)
+ .Select(g => new
+ {
+ Plate = cars.First(c => c.Id == g.Key).LicensePlate,
+ Count = g.Count()
+ })
+ .OrderBy(x => x.Plate)
+ .ToDictionary(x => x.Plate, x => x.Count);
+
+ return counts;
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException("Failed to retrieve rental counts for each car", ex);
+ }
+ }
+
+ ///
+ public async Task> GetTop5CustomersByTotalCostAsync()
+ {
+ try
+ {
+ var rentals = await rentalRepository.GetAsync();
+ var cars = await carRepository.GetAsync();
+ var modelGenerations = await modelGenerationRepository.GetAsync();
+ var customers = await customerRepository.GetAsync();
+
+ var topCustomers = rentals
+ .Join(cars,
+ r => r.CarId,
+ c => c.Id,
+ (r, c) => new { r.CustomerId, c.ModelGenerationId, r.Hours })
+ .Join(modelGenerations,
+ x => x.ModelGenerationId,
+ mg => mg.Id,
+ (x, mg) => new
+ {
+ x.CustomerId,
+ Cost = mg.HourlyRate * x.Hours
+ })
+ .GroupBy(x => x.CustomerId)
+ .Select(g => new
+ {
+ CustomerId = g.Key,
+ Total = g.Sum(x => x.Cost)
+ })
+ .OrderByDescending(x => x.Total)
+ .ThenBy(x => customers.First(c => c.Id == x.CustomerId).FullName)
+ .Take(5)
+ .Join(customers,
+ x => x.CustomerId,
+ c => c.Id,
+ (x, c) => c)
+ .ToList();
+
+ return mapper.Map>(topCustomers);
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException("Failed to retrieve top 5 customers by total cost", ex);
+ }
+ }
+
+ ///
+ public async Task> GetCustomersByModelAsync(int modelId)
+ {
+ try
+ {
+ var rentals = await rentalRepository.GetAsync();
+ var cars = await carRepository.GetAsync();
+ var modelGenerations = await modelGenerationRepository.GetAsync();
+ var customers = await customerRepository.GetAsync();
+
+ var customerIds = rentals
+ .Join(cars,
+ r => r.CarId,
+ c => c.Id,
+ (r, c) => new { r.CustomerId, c.ModelGenerationId })
+ .Where(x => modelGenerations.Any(mg => mg.Id == x.ModelGenerationId && mg.CarModelId == modelId))
+ .Select(x => x.CustomerId)
+ .Distinct()
+ .ToList();
+
+ var customersList = customers
+ .Where(c => customerIds.Contains(c.Id))
+ .OrderBy(c => c.FullName)
+ .ToList();
+
+ return mapper.Map>(customersList);
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException($"Failed to retrieve customers for model ID {modelId}", ex);
+ }
+ }
+
+ ///
+ public async Task> GetCurrentlyRentedCarsAsync(DateTime now)
+ {
+ try
+ {
+ var rentals = await rentalRepository.GetAsync();
+ var cars = await carRepository.GetAsync();
+
+ var currentRentals = rentals
+ .Where(r => r.PickupDateTime <= now && r.PickupDateTime.AddHours(r.Hours) > now)
+ .Join(cars,
+ r => r.CarId,
+ c => c.Id,
+ (r, c) => c)
+ .DistinctBy(c => c.Id)
+ .OrderBy(c => c.LicensePlate)
+ .ToList();
+
+ return mapper.Map>(currentRentals);
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException("Failed to retrieve currently rented cars", ex);
+ }
+ }
+}
diff --git a/CarRental/CarRental.Application/Services/BaseCrudSevice.cs b/CarRental/CarRental.Application/Services/BaseCrudSevice.cs
new file mode 100644
index 000000000..687aa5522
--- /dev/null
+++ b/CarRental/CarRental.Application/Services/BaseCrudSevice.cs
@@ -0,0 +1,123 @@
+using AutoMapper;
+using CarRental.Infrastructure.Repositories.Interfaces;
+
+namespace CarRental.Application.Services;
+///
+/// Base implementation of CRUD service providing common operations for all entities.
+/// Handles mapping between domain models and DTOs, and delegates data access to the repository.
+///
+/// The type of the domain model, must inherit from Model.
+/// The type of the response DTO used for data retrieval.
+/// The type of the DTO used for creating new entities.
+/// The type of the DTO used for updating existing entities.
+public class BaseCrudService
+ (
+ IRepository repository,
+ IMapper mapper
+ )
+
+ : ICrudService
+ where TDto : class?
+{
+ ///
+ /// Retrieves all entities as DTOs.
+ ///
+ /// A collection of all entity DTOs.
+ /// Thrown when retrieval fails.
+ public virtual async Task> GetAsync()
+ {
+ try
+ {
+ var entities = await repository.GetAsync();
+ return mapper.Map>(entities);
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException($"Failed to retrieve {typeof(TModel).Name} entities", ex);
+ }
+ }
+
+ ///
+ /// Retrieves a specific entity by its unique identifier.
+ ///
+ /// The ID of the entity to retrieve.
+ /// The entity DTO if found; otherwise, null.
+ /// Thrown when retrieval fails.
+ public virtual async Task GetAsync(int id)
+ {
+ try
+ {
+ var entity = await repository.GetAsync(id);
+ return entity == null ? null : mapper.Map(entity);
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException($"Failed to retrieve {typeof(TModel).Name} with ID {id}", ex);
+ }
+ }
+
+ ///
+ /// Creates a new entity from the provided DTO.
+ ///
+ /// The DTO containing data for the new entity.
+ /// The created entity as a DTO.
+ /// Thrown when creation fails.
+ public virtual async Task CreateAsync(TCreateDto createDto)
+ {
+ try
+ {
+ var entity = mapper.Map(createDto);
+ await repository.CreateAsync(entity);
+ return mapper.Map(entity);
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException($"Failed to create {typeof(TModel).Name}", ex);
+ }
+ }
+
+ ///
+ /// Updates an existing entity with the provided DTO data.
+ ///
+ /// The ID of the entity to update.
+ /// The DTO containing updated data.
+ /// The updated entity as a DTO if successful; otherwise, null.
+ /// Thrown when update fails.
+ public virtual async Task UpdateAsync(int id, TUpdateDto updateDto)
+ {
+ try
+ {
+ var entity = await repository.GetAsync(id);
+ if (entity == null) return null;
+ mapper.Map(updateDto, entity);
+ await repository.UpdateAsync(entity);
+ return mapper.Map(entity);
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException($"Failed to update {typeof(TModel).Name} with ID {id}", ex);
+ }
+ }
+
+ ///
+ /// Deletes an entity by its unique identifier.
+ ///
+ /// The ID of the entity to delete.
+ /// True if the entity was successfully deleted; otherwise, false.
+ /// Thrown when deletion fails.
+ public virtual async Task DeleteAsync(int id)
+ {
+ try
+ {
+ var exists = await repository.GetAsync(id);
+ if (exists == null) return false;
+
+ await repository.DeleteAsync(id);
+ return true;
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException($"Failed to delete {typeof(TModel).Name} with ID {id}", ex);
+ }
+ }
+}
\ No newline at end of file
diff --git a/CarRental/CarRental.Application/Services/CarModelService.cs b/CarRental/CarRental.Application/Services/CarModelService.cs
new file mode 100644
index 000000000..9ceb8e81f
--- /dev/null
+++ b/CarRental/CarRental.Application/Services/CarModelService.cs
@@ -0,0 +1,15 @@
+using AutoMapper;
+using CarRental.Application.Dtos.CarModels;
+using CarRental.Domain.Models;
+using CarRental.Infrastructure.Repositories.Interfaces;
+
+namespace CarRental.Application.Services;
+
+///
+/// Service for managing CarModel entities.
+/// Provides CRUD operations for CarModels using the underlying repository.
+///
+/// The repository for CarModel data access.
+/// The AutoMapper instance for object mapping.
+public class CarModelService(IRepository repository, IMapper mapper)
+ : BaseCrudService(repository, mapper);
\ No newline at end of file
diff --git a/CarRental/CarRental.Application/Services/CarService.cs b/CarRental/CarRental.Application/Services/CarService.cs
new file mode 100644
index 000000000..bf8467ddd
--- /dev/null
+++ b/CarRental/CarRental.Application/Services/CarService.cs
@@ -0,0 +1,15 @@
+using AutoMapper;
+using CarRental.Application.Dtos.Cars;
+using CarRental.Domain.Models;
+using CarRental.Infrastructure.Repositories.Interfaces;
+
+namespace CarRental.Application.Services;
+
+///
+/// Service for managing Car entities.
+/// Provides CRUD operations for Cars using the underlying repository.
+///
+/// The repository for Car data access.
+/// The AutoMapper instance for object mapping.
+public class CarService(IRepository repository, IMapper mapper)
+ : BaseCrudService(repository, mapper);
diff --git a/CarRental/CarRental.Application/Services/CustomerService.cs b/CarRental/CarRental.Application/Services/CustomerService.cs
new file mode 100644
index 000000000..f3ee84a90
--- /dev/null
+++ b/CarRental/CarRental.Application/Services/CustomerService.cs
@@ -0,0 +1,15 @@
+using AutoMapper;
+using CarRental.Application.Dtos.Customers;
+using CarRental.Domain.Models;
+using CarRental.Infrastructure.Repositories.Interfaces;
+
+namespace CarRental.Application.Services;
+
+///
+/// Service for managing Customer entities.
+/// Provides CRUD operations for Customers using the underlying repository.
+///
+/// The repository for Customer data access.
+/// The AutoMapper instance for object mapping.
+public class CustomerService(IRepository repository, IMapper mapper)
+ : BaseCrudService(repository, mapper);
\ No newline at end of file
diff --git a/CarRental/CarRental.Application/Services/IAnalyticQueryService.cs b/CarRental/CarRental.Application/Services/IAnalyticQueryService.cs
new file mode 100644
index 000000000..4a9518c28
--- /dev/null
+++ b/CarRental/CarRental.Application/Services/IAnalyticQueryService.cs
@@ -0,0 +1,38 @@
+using CarRental.Application.Dtos.Cars;
+using CarRental.Application.Dtos.Customers;
+
+namespace CarRental.Application.Services;
+///
+/// Service for complex car rental analytic queries and reports.
+///
+public interface IAnalyticQueryService
+{
+ ///
+ /// Returns the top 5 most rented cars (by number of rentals).
+ /// Sorted by count descending, then by license plate ascending.
+ ///
+ Task> GetTop5MostRentedCarsAsync();
+
+ ///
+ /// Returns number of rentals for each car.
+ /// Result is sorted by license plate.
+ ///
+ Task> GetRentalCountForEachCarAsync();
+
+ ///
+ /// Returns top 5 customers by total rental cost.
+ /// Sorted by total cost descending, then by full name ascending.
+ ///
+ Task> GetTop5CustomersByTotalCostAsync();
+
+ ///
+ /// Returns all customers who ever rented cars of the specified model.
+ /// Sorted by full name.
+ ///
+ Task> GetCustomersByModelAsync(int modelId);
+
+ ///
+ /// Returns cars that are currently rented at the given moment.
+ ///
+ Task> GetCurrentlyRentedCarsAsync(DateTime now);
+}
diff --git a/CarRental/CarRental.Application/Services/ICrudeService.cs b/CarRental/CarRental.Application/Services/ICrudeService.cs
new file mode 100644
index 000000000..e8ca7e7bb
--- /dev/null
+++ b/CarRental/CarRental.Application/Services/ICrudeService.cs
@@ -0,0 +1,46 @@
+namespace CarRental.Application.Services;
+
+///
+/// Generic service interface for performing CRUD operations on entities.
+/// Defines standard create, read, update, and delete operations using DTOs.
+///
+/// The type of the response DTO used for data retrieval.
+/// The type of the DTO used for creating new entities.
+/// The type of the DTO used for updating existing entities.
+public interface ICrudService
+{
+ ///
+ /// Retrieves all entities as DTOs.
+ ///
+ /// A collection of all entity DTOs.
+ public Task> GetAsync();
+
+ ///
+ /// Retrieves a specific entity by its unique identifier.
+ ///
+ /// The ID of the entity to retrieve.
+ /// The entity DTO if found; otherwise, null.
+ public Task GetAsync(int id);
+
+ ///
+ /// Creates a new entity from the provided DTO.
+ ///
+ /// The DTO containing data for the new entity.
+ /// The created entity as a DTO.
+ public Task CreateAsync(TCreateDto createDto);
+
+ ///
+ /// Updates an existing entity with the provided DTO data.
+ ///
+ /// The ID of the entity to update.
+ /// The DTO containing updated data.
+ /// The updated entity as a DTO if successful; otherwise, null.
+ public Task UpdateAsync(int id, TUpdateDto updateDto);
+
+ ///
+ /// Deletes an entity by its unique identifier.
+ ///
+ /// The ID of the entity to delete.
+ /// True if the entity was successfully deleted; otherwise, false.
+ public Task DeleteAsync(int id);
+}
\ No newline at end of file
diff --git a/CarRental/CarRental.Application/Services/ModelGenerationService.cs b/CarRental/CarRental.Application/Services/ModelGenerationService.cs
new file mode 100644
index 000000000..f1b2ae7f7
--- /dev/null
+++ b/CarRental/CarRental.Application/Services/ModelGenerationService.cs
@@ -0,0 +1,15 @@
+using AutoMapper;
+using CarRental.Application.Dtos.ModelGenerations;
+using CarRental.Domain.Models;
+using CarRental.Infrastructure.Repositories.Interfaces;
+
+namespace CarRental.Application.Services;
+
+///
+/// Service for managing ModelGeneration entities.
+/// Provides CRUD operations for ModelGenerations using the underlying repository.
+///
+/// The repository for ModelGeneration data access.
+/// The AutoMapper instance for object mapping.
+public class ModelGenerationService(IRepository repository, IMapper mapper)
+ : BaseCrudService(repository, mapper);
diff --git a/CarRental/CarRental.Application/Services/RentalService.cs b/CarRental/CarRental.Application/Services/RentalService.cs
new file mode 100644
index 000000000..311baf70b
--- /dev/null
+++ b/CarRental/CarRental.Application/Services/RentalService.cs
@@ -0,0 +1,15 @@
+using AutoMapper;
+using CarRental.Application.Dtos.Rentals;
+using CarRental.Domain.Models;
+using CarRental.Infrastructure.Repositories.Interfaces;
+
+namespace CarRental.Application.Services;
+
+///
+/// Service for managing Rental entities.
+/// Provides CRUD operations for Rentals using the underlying repository.
+///
+/// The repository for Rental data access.
+/// The AutoMapper instance for object mapping.
+public class RentalService(IRepository repository, IMapper mapper)
+ : BaseCrudService(repository, mapper);
diff --git a/CarRental/CarRental.Application/Validation/EnumRangeAttribute.cs b/CarRental/CarRental.Application/Validation/EnumRangeAttribute.cs
new file mode 100644
index 000000000..652da56f9
--- /dev/null
+++ b/CarRental/CarRental.Application/Validation/EnumRangeAttribute.cs
@@ -0,0 +1,44 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace CarRental.Application.Validation;
+
+///
+/// Validation attribute that ensures a value is a valid member of the specified enumeration type.
+/// Works with both string and integer enum values, compatible with JsonStringEnumConverter.
+///
+/// The type of the enumeration to validate against
+public class EnumRangeAttribute(Type enumType) : ValidationAttribute
+{
+ ///
+ /// Validates that the specified value is a defined member of the enumeration.
+ /// Supports both string names and integer values of the enum.
+ ///
+ /// The value to validate
+ /// The context information about the validation operation
+ /// ValidationResult.Success if valid; otherwise, an error message
+ protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
+ {
+ if (value == null)
+ return ValidationResult.Success;
+
+ if (value is string stringValue)
+ {
+ if (Enum.TryParse(enumType, stringValue, true, out var parsedEnum) &&
+ Enum.IsDefined(enumType, parsedEnum))
+ {
+ return ValidationResult.Success;
+ }
+
+ var validValues = string.Join(", ", Enum.GetNames(enumType));
+ return new ValidationResult($"The field {validationContext.DisplayName} must be one of: {validValues}. Received: '{stringValue}'");
+ }
+
+ if (!Enum.IsDefined(enumType, value))
+ {
+ var validValues = string.Join(", ", Enum.GetNames(enumType));
+ return new ValidationResult($"The field {validationContext.DisplayName} must be one of: {validValues}. Received: {value}");
+ }
+
+ return ValidationResult.Success;
+ }
+}
diff --git a/CarRental/CarRental.Consumer/CarRental.Consumer.csproj b/CarRental/CarRental.Consumer/CarRental.Consumer.csproj
new file mode 100644
index 000000000..d8a755ddb
--- /dev/null
+++ b/CarRental/CarRental.Consumer/CarRental.Consumer.csproj
@@ -0,0 +1,22 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/CarRental/CarRental.Consumer/Program.cs b/CarRental/CarRental.Consumer/Program.cs
new file mode 100644
index 000000000..669d52c8f
--- /dev/null
+++ b/CarRental/CarRental.Consumer/Program.cs
@@ -0,0 +1,45 @@
+using CarRental.Application.Dtos.CarModels;
+using CarRental.Application.Dtos.Cars;
+using CarRental.Application.Dtos.Customers;
+using CarRental.Application.Dtos.ModelGenerations;
+using CarRental.Application.Dtos.Rentals;
+using CarRental.Application.Profiles;
+using CarRental.Application.Services;
+using CarRental.Customer.Services;
+using CarRental.Infrastructure.Persistence;
+using CarRental.Infrastructure.Repositories;
+using CarRental.Infrastructure.Repositories.Interfaces;
+using CarRental.ServiceDefaults;
+using Microsoft.EntityFrameworkCore;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.AddServiceDefaults();
+
+builder.Services.AddDbContext(options =>
+{
+ var connectionString = builder.Configuration.GetConnectionString("carrentaldb")
+ ?? throw new InvalidOperationException("Connection string 'carrentaldb' not found.");
+
+ options.UseSqlServer(connectionString);
+ options.UseLazyLoadingProxies();
+});
+
+builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
+builder.Services.AddScoped, CarModelService>();
+builder.Services.AddScoped, ModelGenerationService>();
+builder.Services.AddScoped, CarService>();
+builder.Services.AddScoped, CustomerService>();
+builder.Services.AddScoped, RentalService>();
+
+builder.Services.AddAutoMapper(config =>
+{
+ config.AddProfile(new MappingProfile());
+});
+
+builder.Services.AddGrpc();
+
+var app = builder.Build();
+app.MapDefaultEndpoints();
+app.MapGrpcService();
+app.Run();
\ No newline at end of file
diff --git a/CarRental/CarRental.Consumer/Properties/launchSettings.json b/CarRental/CarRental.Consumer/Properties/launchSettings.json
new file mode 100644
index 000000000..06a3bcd32
--- /dev/null
+++ b/CarRental/CarRental.Consumer/Properties/launchSettings.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "http://localhost:5026",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "https://localhost:7177;http://localhost:5026",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/CarRental/CarRental.Consumer/Services/RequestStreamingService.cs b/CarRental/CarRental.Consumer/Services/RequestStreamingService.cs
new file mode 100644
index 000000000..a3965bfad
--- /dev/null
+++ b/CarRental/CarRental.Consumer/Services/RequestStreamingService.cs
@@ -0,0 +1,121 @@
+using CarRental.Application.Dtos.Cars;
+using CarRental.Application.Dtos.Customers;
+using CarRental.Application.Dtos.Grpc;
+using CarRental.Application.Dtos.Rentals;
+using CarRental.Application.Services;
+using Grpc.Core;
+
+namespace CarRental.Customer.Services;
+
+///
+/// gRPC service for streaming car rental requests.
+///
+public class RequestStreamingService(
+ ILogger logger,
+ IServiceScopeFactory scopeFactory) : RentalStreaming.RentalStreamingBase
+{
+ public override async Task StreamRentals(
+ IAsyncStreamReader requestStream,
+ IServerStreamWriter responseStream,
+ ServerCallContext context)
+ {
+ logger.LogInformation("Started bidirectional streaming from {Peer}", context.Peer);
+
+ try
+ {
+ await foreach (var request in requestStream.ReadAllAsync(context.CancellationToken))
+ {
+ logger.LogInformation(
+ "Received rental request: customer {CustomerId}, car {CarId}, pickup {Pickup}, hours {Hours}",
+ request.CustomerId, request.CarId, request.PickupDateTime.ToDateTime(), request.Hours);
+
+ var (success, rentalId, errorMessage) = await ProcessRentalRequest(request);
+
+ await responseStream.WriteAsync(new RentalResponseMessage
+ {
+ Success = success,
+ Message = success
+ ? $"Rental created successfully (ID: {rentalId}) for customer {request.CustomerId}"
+ : $"Failed to create rental: {errorMessage}",
+ RentalId = success ? rentalId : 0,
+ ErrorCode = success ? "" : "RENTAL_CREATION_FAILED"
+ });
+
+ logger.LogInformation("Sent response for customer {CustomerId} → success: {Success}",
+ request.CustomerId, success);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ logger.LogWarning("Streaming cancelled by client {Peer}", context.Peer);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Unexpected error in streaming");
+ throw;
+ }
+
+ logger.LogInformation("Finished streaming rentals");
+ }
+
+ private async Task<(bool Success, long RentalId, string ErrorMessage)> ProcessRentalRequest(RentalRequestMessage request)
+ {
+ using var scope = scopeFactory.CreateScope();
+
+ try
+ {
+ var rentalService = scope.ServiceProvider.GetRequiredService>();
+ var customerService = scope.ServiceProvider.GetRequiredService>();
+ var carService = scope.ServiceProvider.GetRequiredService>();
+
+ var customer = await customerService.GetAsync(request.CustomerId);
+ if (customer == null)
+ {
+ logger.LogWarning("Customer not found: {CustomerId}", request.CustomerId);
+ return (false, 0, $"Customer {request.CustomerId} not found");
+ }
+
+ var car = await carService.GetAsync(request.CarId);
+ if (car == null)
+ {
+ logger.LogWarning("Car not found: {CarId}", request.CarId);
+ return (false, 0, $"Car {request.CarId} not found");
+ }
+
+ var pickupDateTime = request.PickupDateTime.ToDateTime();
+
+ if (pickupDateTime < DateTime.UtcNow)
+ {
+ logger.LogWarning("Pickup date is in the past: {PickupDateTime}", pickupDateTime);
+ return (false, 0, "Pickup date cannot be in the past");
+ }
+
+ if (request.Hours <= 0)
+ {
+ logger.LogWarning("Invalid rental duration: {Hours}", request.Hours);
+ return (false, 0, "Rental duration must be positive");
+ }
+
+ var createDto = new RentalCreateDto
+ {
+ CustomerId = request.CustomerId,
+ CarId = request.CarId,
+ PickupDateTime = pickupDateTime,
+ Hours = request.Hours
+ };
+
+ var createdRental = await rentalService.CreateAsync(createDto);
+
+ logger.LogInformation(
+ "Rental created successfully: ID {RentalId}, customer {CustomerId}, car {CarId}",
+ createdRental?.Id, request.CustomerId, request.CarId);
+
+ return (true, createdRental?.Id ?? 0, "");
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to process rental request for customer {CustomerId}", request.CustomerId);
+ return (false, 0, ex.Message);
+ }
+ }
+}
\ No newline at end of file
diff --git a/CarRental/CarRental.Consumer/appsettings.Development.json b/CarRental/CarRental.Consumer/appsettings.Development.json
new file mode 100644
index 000000000..b99b28356
--- /dev/null
+++ b/CarRental/CarRental.Consumer/appsettings.Development.json
@@ -0,0 +1,11 @@
+{
+ "ConnectionStrings": {
+ "mysqldb": "Server=localhost;Port=3306;Database=realestate;User=root;Password=P@ssw0rd;"
+ },
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
\ No newline at end of file
diff --git a/CarRental/CarRental.Consumer/appsettings.json b/CarRental/CarRental.Consumer/appsettings.json
new file mode 100644
index 000000000..961ea5fe6
--- /dev/null
+++ b/CarRental/CarRental.Consumer/appsettings.json
@@ -0,0 +1,24 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*",
+ "Kestrel": {
+ "Endpoints": {
+ "Https": {
+ "Url": "https://localhost:7002",
+ "Protocols": "Http2"
+ },
+ "Http": {
+ "Url": "http://localhost:5002",
+ "Protocols": "Http2"
+ }
+ }
+ },
+ "ConnectionStrings": {
+ "mysqldb": "Server=localhost;Port=3306;Database=realestate;User=root;Password=P@ssw0rd;"
+ }
+}
\ No newline at end of file
diff --git a/CarRental/CarRental.Domain/CarRental.Domain.csproj b/CarRental/CarRental.Domain/CarRental.Domain.csproj
new file mode 100644
index 000000000..e1f6a46db
--- /dev/null
+++ b/CarRental/CarRental.Domain/CarRental.Domain.csproj
@@ -0,0 +1,11 @@
+
+
+
+ Library
+ net8.0
+ enable
+ True
+ enable
+
+
+
\ No newline at end of file
diff --git a/CarRental/CarRental.Domain/Data/DataSeed.cs b/CarRental/CarRental.Domain/Data/DataSeed.cs
new file mode 100644
index 000000000..0947e52f6
--- /dev/null
+++ b/CarRental/CarRental.Domain/Data/DataSeed.cs
@@ -0,0 +1,82 @@
+using CarRental.Domain.Enums;
+using CarRental.Domain.Models;
+
+namespace CarRental.Domain.Data;
+
+///
+/// Provides seed data for the Car Rental domain entities.
+/// This class contains initial data for database seeding and testing purposes.
+///
+public class DataSeed
+{
+ ///
+ /// Gets the list of car models for seeding.
+ ///
+ public List CarModels { get; } = new()
+ {
+ new() { Id = 1, Name = "Toyota Camry", DriverType = DriverType.FrontWheelDrive, SeatingCapacity = 5, BodyType = BodyType.Sedan, CarClass = CarClass.Intermediate },
+ new() { Id = 2, Name = "Kia Rio", DriverType = DriverType.FrontWheelDrive, SeatingCapacity = 5, BodyType = BodyType.Hatchback, CarClass = CarClass.Economy },
+ new() { Id = 3, Name = "BMW X5", DriverType = DriverType.AllWheelDrive, SeatingCapacity = 5, BodyType = BodyType.SUV, CarClass = CarClass.Premium },
+ new() { Id = 4, Name = "Hyundai Solaris", DriverType = DriverType.FrontWheelDrive, SeatingCapacity = 5, BodyType = BodyType.Sedan, CarClass = CarClass.Economy },
+ new() { Id = 5, Name = "Volkswagen Tiguan", DriverType = DriverType.AllWheelDrive, SeatingCapacity = 5, BodyType = BodyType.Crossover, CarClass = CarClass.Intermediate },
+ };
+
+ ///
+ /// Gets the list of model generations for seeding.
+ ///
+ public List ModelGenerations { get; } = new()
+ {
+ new() { Id = 1, CarModelId = 1, ProductionYear = 2023, EngineVolumeLiters = 2.5m, TransmissionType = TransmissionType.Automatic, HourlyRate = 1200 },
+ new() { Id = 2, CarModelId = 1, ProductionYear = 2019, EngineVolumeLiters = 2.0m, TransmissionType = TransmissionType.CVT, HourlyRate = 950 },
+ new() { Id = 3, CarModelId = 2, ProductionYear = 2024, EngineVolumeLiters = 1.6m, TransmissionType = TransmissionType.Automatic, HourlyRate = 650 },
+ new() { Id = 4, CarModelId = 3, ProductionYear = 2022, EngineVolumeLiters = 3.0m, TransmissionType = TransmissionType.Automatic, HourlyRate = 3500 },
+ new() { Id = 5, CarModelId = 4, ProductionYear = 2023, EngineVolumeLiters = 1.6m, TransmissionType = TransmissionType.Automatic, HourlyRate = 700 },
+ new() { Id = 6, CarModelId = 5, ProductionYear = 2021, EngineVolumeLiters = 2.0m, TransmissionType = TransmissionType.DualClutch, HourlyRate = 1400 },
+ };
+
+ ///
+ /// Gets the list of cars for seeding.
+ ///
+ public List Cars { get; } = new()
+ {
+ new() { Id = 1, LicensePlate = "А123ВС 777", Color = "Черный", ModelGenerationId = 1 },
+ new() { Id = 2, LicensePlate = "В456ОР 777", Color = "Белый", ModelGenerationId = 2 },
+ new() { Id = 3, LicensePlate = "Е789КХ 777", Color = "Синий", ModelGenerationId = 3 },
+ new() { Id = 4, LicensePlate = "К001МР 777", Color = "Серебро", ModelGenerationId = 4 },
+ new() { Id = 5, LicensePlate = "М234ТН 777", Color = "Красный", ModelGenerationId = 5 },
+ new() { Id = 6, LicensePlate = "Н567УХ 777", Color = "Серый", ModelGenerationId = 1 },
+ new() { Id = 7, LicensePlate = "О890ЦВ 777", Color = "Черный", ModelGenerationId = 3 },
+ };
+
+ ///
+ /// Gets the list of customers for seeding.
+ ///
+ public List Customers { get; } = new()
+ {
+ new() { Id = 1, DriverLicenseNumber = "1234 567890", FullName = "Иванов Иван Иванович", DateOfBirth = new DateOnly(1985, 3, 12) },
+ new() { Id = 2, DriverLicenseNumber = "2345 678901", FullName = "Петрова Анна Сергеевна", DateOfBirth = new DateOnly(1992, 7, 19) },
+ new() { Id = 3, DriverLicenseNumber = "3456 789012", FullName = "Сидоров Алексей Петрович", DateOfBirth = new DateOnly(1978, 11, 5) },
+ new() { Id = 4, DriverLicenseNumber = "4567 890123", FullName = "Кузнецова Мария Дмитриевна", DateOfBirth = new DateOnly(1990, 4, 28) },
+ new() { Id = 5, DriverLicenseNumber = "5678 901234", FullName = "Смирнов Дмитрий Александрович", DateOfBirth = new DateOnly(1982, 9, 15) },
+ new() { Id = 6, DriverLicenseNumber = "6789 012345", FullName = "Волкова Ольга Николаевна", DateOfBirth = new DateOnly(1995, 1, 8) },
+ };
+
+ ///
+ /// Gets the list of rentals for seeding.
+ ///
+ public List Rentals { get; } = new()
+ {
+ new() { Id = 1, CustomerId = 1, CarId = 1, PickupDateTime = new DateTime(2025, 10, 1, 9, 0, 0), Hours = 24 },
+ new() { Id = 2, CustomerId = 2, CarId = 3, PickupDateTime = new DateTime(2025, 10, 3, 14, 0, 0), Hours = 8 },
+ new() { Id = 3, CustomerId = 1, CarId = 1, PickupDateTime = new DateTime(2025, 9, 15, 10, 0, 0), Hours = 48 },
+ new() { Id = 4, CustomerId = 3, CarId = 4, PickupDateTime = new DateTime(2025, 10, 5, 11, 30, 0), Hours = 5 },
+ new() { Id = 5, CustomerId = 4, CarId = 1, PickupDateTime = new DateTime(2025, 10, 7, 8, 0, 0), Hours = 72 },
+ new() { Id = 6, CustomerId = 2, CarId = 3, PickupDateTime = new DateTime(2025, 9, 20, 16, 0, 0), Hours = 24 },
+ new() { Id = 7, CustomerId = 5, CarId = 7, PickupDateTime = new DateTime(2025, 10, 10, 12, 0, 0), Hours = 12 },
+ new() { Id = 8, CustomerId = 1, CarId = 6, PickupDateTime = new DateTime(2025, 10, 12, 9, 0, 0), Hours = 36 },
+ new() { Id = 9, CustomerId = 6, CarId = 2, PickupDateTime = new DateTime(2025, 10, 14, 13, 0, 0), Hours = 4 },
+ new() { Id = 10, CustomerId = 3, CarId = 1, PickupDateTime = new DateTime(2025, 9, 25, 10, 0, 0), Hours = 24 },
+ new() { Id = 11, CustomerId = 2, CarId = 3, PickupDateTime = new DateTime(2025, 10, 10, 8, 0, 0), Hours = 240 },
+ new() { Id = 12, CustomerId = 5, CarId = 1, PickupDateTime = new DateTime(2025, 10, 15, 14, 0, 0), Hours = 36 },
+ };
+}
\ No newline at end of file
diff --git a/CarRental/CarRental.Domain/Enums/BodyType.cs b/CarRental/CarRental.Domain/Enums/BodyType.cs
new file mode 100644
index 000000000..ffd283770
--- /dev/null
+++ b/CarRental/CarRental.Domain/Enums/BodyType.cs
@@ -0,0 +1,57 @@
+namespace CarRental.Domain.Enums;
+
+///
+/// Represents the body style or body type of a vehicle.
+///
+public enum BodyType
+{
+ ///
+ /// Four-door passenger car with a separate trunk (saloon).
+ ///
+ Sedan,
+
+ ///
+ /// Compact car with a rear door that opens upwards, giving access to the cargo area.
+ ///
+ Hatchback,
+
+ ///
+ /// Similar to a sedan but with a fastback rear end and a hatch-like tailgate.
+ ///
+ Liftback,
+
+ ///
+ /// Two-door car with a fixed roof and a sporty design, usually with limited rear seating.
+ ///
+ Coupe,
+
+ ///
+ /// Car with a retractable or removable roof (soft-top or hard-top convertible).
+ ///
+ Convertible,
+
+ ///
+ /// Sport Utility Vehicle — taller vehicle with off-road capability and higher ground clearance.
+ ///
+ SUV,
+
+ ///
+ /// Crossover Utility Vehicle — combines features of a passenger car and an SUV (usually unibody construction).
+ ///
+ Crossover,
+
+ ///
+ /// Minivan — family-oriented vehicle with sliding doors and flexible seating arrangements.
+ ///
+ Minivan,
+
+ ///
+ /// Pickup truck — vehicle with an open cargo bed at the rear, often used for hauling.
+ ///
+ Pickup,
+
+ ///
+ /// Station wagon — passenger car with an extended roofline and a rear door that opens upwards (estate car).
+ ///
+ StationWagon
+}
\ No newline at end of file
diff --git a/CarRental/CarRental.Domain/Enums/CarClass.cs b/CarRental/CarRental.Domain/Enums/CarClass.cs
new file mode 100644
index 000000000..9dac81d42
--- /dev/null
+++ b/CarRental/CarRental.Domain/Enums/CarClass.cs
@@ -0,0 +1,57 @@
+namespace CarRental.Domain.Enums;
+
+///
+/// Represents the rental category or car class, which typically affects pricing, features and insurance requirements.
+///
+public enum CarClass
+{
+ ///
+ /// Economy class — the most affordable option, usually small compact cars with basic features and low fuel consumption.
+ ///
+ Economy,
+
+ ///
+ /// Compact class — slightly larger than economy, offering more interior space and better comfort while remaining economical.
+ ///
+ Compact,
+
+ ///
+ /// Intermediate class — mid-size vehicles providing a good balance between space, comfort and cost (often called "midsize").
+ ///
+ Intermediate,
+
+ ///
+ /// Standard class — full-size sedans or similar vehicles with more legroom and trunk space than intermediate.
+ ///
+ Standard,
+
+ ///
+ /// Full-size class — large sedans or crossovers designed for maximum passenger and luggage capacity.
+ ///
+ FullSize,
+
+ ///
+ /// Luxury class — premium vehicles with high-end interior materials, advanced technology and superior comfort.
+ ///
+ Luxury,
+
+ ///
+ /// Premium class — top-tier luxury vehicles, often including executive sedans, high-performance models or ultra-luxury brands.
+ ///
+ Premium,
+
+ ///
+ /// SUV class — sport utility vehicles, usually offering higher seating position, more space and sometimes all-wheel drive.
+ ///
+ SUV,
+
+ ///
+ /// Minivan class — family-oriented vehicles with sliding doors, flexible seating configurations and large cargo capacity.
+ ///
+ Minivan,
+
+ ///
+ /// Sport class — performance-oriented vehicles with sporty handling, powerful engines and dynamic design.
+ ///
+ Sport
+}
\ No newline at end of file
diff --git a/CarRental/CarRental.Domain/Enums/DriverType.cs b/CarRental/CarRental.Domain/Enums/DriverType.cs
new file mode 100644
index 000000000..ee2911577
--- /dev/null
+++ b/CarRental/CarRental.Domain/Enums/DriverType.cs
@@ -0,0 +1,36 @@
+namespace CarRental.Domain.Enums;
+
+///
+/// Represents the drivetrain type (which wheels receive power from the engine).
+/// This affects vehicle handling, traction, fuel efficiency and suitability for different conditions.
+///
+public enum DriverType
+{
+ ///
+ /// Front-Wheel Drive (FWD) — power is delivered to the front wheels.
+ /// Common in most modern compact and mid-size cars due to good traction in wet/snowy conditions
+ /// and efficient use of interior space.
+ ///
+ FrontWheelDrive,
+
+ ///
+ /// Rear-Wheel Drive (RWD) — power is sent to the rear wheels.
+ /// Provides better handling balance and is preferred in sports cars,
+ /// rear-engine vehicles and many classic/luxury models.
+ ///
+ RearWheelDrive,
+
+ ///
+ /// All-Wheel Drive (AWD) — power is distributed to all four wheels permanently or on-demand.
+ /// Offers improved traction and stability, especially in adverse weather,
+ /// without the full complexity of traditional 4×4 systems.
+ ///
+ AllWheelDrive,
+
+ ///
+ /// Four-Wheel Drive (4WD / 4×4) — typically a more robust system with selectable modes
+ /// (2WD / 4WD high / 4WD low) and often a transfer case.
+ /// Designed primarily for off-road capability and heavy-duty use.
+ ///
+ FourWheelDrive
+}
\ No newline at end of file
diff --git a/CarRental/CarRental.Domain/Enums/TransmissionType.cs b/CarRental/CarRental.Domain/Enums/TransmissionType.cs
new file mode 100644
index 000000000..043f1482a
--- /dev/null
+++ b/CarRental/CarRental.Domain/Enums/TransmissionType.cs
@@ -0,0 +1,37 @@
+namespace CarRental.Domain.Enums;
+
+///
+/// Represents the type of vehicle transmission (gearbox), which determines how power is transferred from the engine to the wheels.
+///
+public enum TransmissionType
+{
+ ///
+ /// Manual transmission — requires the driver to manually shift gears using a clutch pedal and gear stick.
+ /// Offers more control and often better fuel efficiency, popular among enthusiasts.
+ ///
+ Manual,
+
+ ///
+ /// Automatic transmission — shifts gears automatically without driver input.
+ /// Provides convenience and comfort, especially in city driving; modern versions are very efficient.
+ ///
+ Automatic,
+
+ ///
+ /// Robotic transmission (automated manual / AMT) — a manual gearbox with computer-controlled clutch and gear shifts.
+ /// Usually cheaper than classic automatic, but can have noticeable shift pauses.
+ ///
+ Robotic,
+
+ ///
+ /// Continuously Variable Transmission (CVT) — uses a belt/pulley system instead of fixed gears.
+ /// Provides seamless acceleration without noticeable gear shifts, often very fuel-efficient.
+ ///
+ CVT,
+
+ ///
+ /// Dual-clutch transmission (DCT / DSG / PDK) — uses two separate clutches for odd and even gears.
+ /// Combines fast, smooth shifts like a manual with the convenience of an automatic; very popular in performance cars.
+ ///
+ DualClutch
+}
\ No newline at end of file
diff --git a/CarRental/CarRental.Domain/Models/Abstract/Model.cs b/CarRental/CarRental.Domain/Models/Abstract/Model.cs
new file mode 100644
index 000000000..9ed10db9f
--- /dev/null
+++ b/CarRental/CarRental.Domain/Models/Abstract/Model.cs
@@ -0,0 +1,12 @@
+namespace CarRental.Domain.Models.Abstract;
+
+///
+/// Base class for all entities with an identifier
+///
+public abstract class Model
+{
+ ///
+ /// identifier of the entity
+ ///
+ public virtual int Id { get; set; }
+}
\ No newline at end of file
diff --git a/CarRental/CarRental.Domain/Models/Car.cs b/CarRental/CarRental.Domain/Models/Car.cs
new file mode 100644
index 000000000..b3ffe87bd
--- /dev/null
+++ b/CarRental/CarRental.Domain/Models/Car.cs
@@ -0,0 +1,24 @@
+using CarRental.Domain.Models.Abstract;
+
+namespace CarRental.Domain.Models;
+
+///
+/// Represents a car in the car rental service.
+///
+public class Car : Model
+{
+ ///
+ /// License plate number
+ ///
+ public required string LicensePlate { get; set; }
+
+ ///
+ /// Color of the vehicle
+ ///
+ public required string Color { get; set; }
+
+ ///
+ /// Foreign key to the model generation this car belongs to
+ ///
+ public required int ModelGenerationId { get; set; }
+}
diff --git a/CarRental/CarRental.Domain/Models/CarModel.cs b/CarRental/CarRental.Domain/Models/CarModel.cs
new file mode 100644
index 000000000..dfa895622
--- /dev/null
+++ b/CarRental/CarRental.Domain/Models/CarModel.cs
@@ -0,0 +1,35 @@
+using CarRental.Domain.Enums;
+using CarRental.Domain.Models.Abstract;
+
+namespace CarRental.Domain.Models;
+
+///
+/// Car model (e.g. Toyota Camry, BMW X5)
+///
+public class CarModel : Model
+{
+ ///
+ /// Name of the model
+ ///
+ public required string Name { get; set; }
+
+ ///
+ /// Type of drivetrain
+ ///
+ public required DriverType DriverType { get; set; }
+
+ ///
+ /// Number of seats (including driver)
+ ///
+ public required byte SeatingCapacity { get; set; }
+
+ ///
+ /// Body style / type
+ ///
+ public required BodyType BodyType { get; set; }
+
+ ///
+ /// Vehicle class / market segment
+ ///
+ public required CarClass CarClass { get; set; }
+}
diff --git a/CarRental/CarRental.Domain/Models/Customer.cs b/CarRental/CarRental.Domain/Models/Customer.cs
new file mode 100644
index 000000000..1f0e60a18
--- /dev/null
+++ b/CarRental/CarRental.Domain/Models/Customer.cs
@@ -0,0 +1,24 @@
+using CarRental.Domain.Models.Abstract;
+
+namespace CarRental.Domain.Models;
+
+///
+/// Customer / renter of the vehicle
+///
+public class Customer : Model
+{
+ ///
+ /// Driver's license number (used as unique business identifier)
+ ///
+ public required string DriverLicenseNumber { get; set; }
+
+ ///
+ /// Full name of the customer
+ ///
+ public required string FullName { get; set; }
+
+ ///
+ /// Date of birth
+ ///
+ public required DateOnly DateOfBirth { get; set; }
+}
diff --git a/CarRental/CarRental.Domain/Models/ModelGeneration.cs b/CarRental/CarRental.Domain/Models/ModelGeneration.cs
new file mode 100644
index 000000000..90a9dcf4b
--- /dev/null
+++ b/CarRental/CarRental.Domain/Models/ModelGeneration.cs
@@ -0,0 +1,34 @@
+using CarRental.Domain.Models.Abstract;
+using CarRental.Domain.Enums;
+
+namespace CarRental.Domain.Models;
+///
+/// Generation / specific version of a car model
+///
+public class ModelGeneration : Model
+{
+ ///
+ /// Year of manufacture / start of production for this generation
+ ///
+ public required int ProductionYear { get; set; }
+
+ ///
+ /// Engine displacement in liters
+ ///
+ public required decimal EngineVolumeLiters { get; set; }
+
+ ///
+ /// Type of transmission
+ ///
+ public required TransmissionType TransmissionType { get; set; }
+
+ ///
+ /// Hourly rental price for this generation
+ ///
+ public required decimal HourlyRate { get; set; }
+
+ ///
+ /// Foreign key to the base car model
+ ///
+ public required int CarModelId { get; set; }
+}
diff --git a/CarRental/CarRental.Domain/Models/Rental.cs b/CarRental/CarRental.Domain/Models/Rental.cs
new file mode 100644
index 000000000..75f95b4ef
--- /dev/null
+++ b/CarRental/CarRental.Domain/Models/Rental.cs
@@ -0,0 +1,34 @@
+using CarRental.Domain.Models.Abstract;
+
+namespace CarRental.Domain.Models;
+
+///
+/// Rental agreement / contract
+///
+public class Rental : Model
+{
+ ///
+ /// Customer who rents the car
+ ///
+ public required int CustomerId { get; set; }
+
+ ///
+ /// Car being rented
+ ///
+ public required int CarId { get; set; }
+
+ ///
+ /// Date and time when the vehicle was handed over
+ ///
+ public required DateTime PickupDateTime { get; set; }
+
+ ///
+ /// Duration of the rental in hours
+ ///
+ public required int Hours { get; set; }
+
+ ///
+ /// Calculated expected return time
+ ///
+ public DateTime ExpectedReturnDateTime => PickupDateTime.AddHours(Hours);
+}
diff --git a/CarRental/CarRental.Infrastructure/CarRental.Infrastructure.csproj b/CarRental/CarRental.Infrastructure/CarRental.Infrastructure.csproj
new file mode 100644
index 000000000..d83da6617
--- /dev/null
+++ b/CarRental/CarRental.Infrastructure/CarRental.Infrastructure.csproj
@@ -0,0 +1,25 @@
+
+
+
+ net8.0
+ enable
+ enable
+ false
+ false
+ True
+
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/CarRental/CarRental.Infrastructure/Data/EfDataSeeder.cs b/CarRental/CarRental.Infrastructure/Data/EfDataSeeder.cs
new file mode 100644
index 000000000..83650b8c3
--- /dev/null
+++ b/CarRental/CarRental.Infrastructure/Data/EfDataSeeder.cs
@@ -0,0 +1,303 @@
+using CarRental.Infrastructure.Data.Interfaces;
+using CarRental.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using CarRental.Domain.Data;
+using CarRental.Domain.Enums;
+using CarRental.Domain.Models;
+
+namespace CarRental.Infrastructure.Data;
+
+///
+/// Entity Framework data seeder that uses predefined data from DataSeed class.
+/// Provides methods for seeding, clearing and resetting database data for Car Rental application.
+///
+public class EfDataSeeder(AppDbContext context, ILogger logger, DataSeed data)
+ : IDataSeeder
+{
+ ///
+ /// Seeds the database with initial test or development data.
+ /// Entities are seeded in dependency order: independent entities first.
+ /// Explicit Id values from seed data are ignored — database generates them automatically.
+ ///
+ public async Task SeedAsync()
+ {
+ logger.LogInformation("Starting database seeding...");
+
+ try
+ {
+ await SeedCarModelsAsync();
+ await SeedModelGenerationsAsync();
+ await SeedCarsAsync();
+ await SeedCustomersAsync();
+ await SeedRentalsAsync();
+
+ logger.LogInformation("Database seeding completed successfully");
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "An error occurred during database seeding");
+ throw;
+ }
+ }
+
+ ///
+ /// Removes all data from the database tables.
+ /// Entities are cleared in reverse dependency order to avoid foreign key violations.
+ ///
+ public async Task ClearAsync()
+ {
+ logger.LogInformation("Clearing all database data...");
+
+ try
+ {
+ await ClearRentalsAsync();
+ await ClearCarsAsync();
+ await ClearModelGenerationsAsync();
+ await ClearCarModelsAsync();
+ await ClearCustomersAsync();
+
+ logger.LogInformation("Database clearing completed successfully");
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "An error occurred during database clearing");
+ throw;
+ }
+ }
+
+ ///
+ /// Clears the database and then seeds it with initial data.
+ /// Useful for development, testing and demo scenarios.
+ ///
+ public async Task ResetAsync()
+ {
+ logger.LogInformation("Resetting database...");
+ await ClearAsync();
+ await SeedAsync();
+ logger.LogInformation("Database reset completed successfully");
+ }
+
+ ///
+ /// Seeds CarModels table if it is empty, ignoring explicit Id values.
+ ///
+ private async Task SeedCarModelsAsync()
+ {
+ if (await context.CarModels.AnyAsync())
+ {
+ logger.LogInformation("CarModels already exist, skipping seeding");
+ return;
+ }
+
+ logger.LogInformation("Seeding {Count} car models", data.CarModels.Count);
+
+ var modelsToInsert = data.CarModels.Select(m => new CarModel
+ {
+ Name = m.Name,
+ DriverType = m.DriverType,
+ SeatingCapacity = m.SeatingCapacity,
+ BodyType = m.BodyType,
+ CarClass = m.CarClass
+ }).ToList();
+
+ context.CarModels.AddRange(modelsToInsert);
+ await context.SaveChangesAsync();
+
+ logger.LogInformation("CarModels seeded successfully");
+ }
+
+ ///
+ /// Seeds ModelGenerations table if it is empty, ignoring explicit Id values.
+ /// References to CarModel are preserved via original Id mapping.
+ ///
+ private async Task SeedModelGenerationsAsync()
+ {
+ if (await context.ModelGenerations.AnyAsync())
+ {
+ logger.LogInformation("ModelGenerations already exist, skipping seeding");
+ return;
+ }
+
+ logger.LogInformation("Seeding {Count} model generations", data.ModelGenerations.Count);
+
+ // Создаём словарь для сопоставления старого Id модели → новой (сгенерированной базой)
+ var carModelIdMap = (await context.CarModels.ToListAsync())
+ .ToDictionary(
+ cm => data.CarModels.First(ds => ds.Name == cm.Name && ds.CarClass == cm.CarClass).Id,
+ cm => cm.Id);
+
+ var generationsToInsert = data.ModelGenerations.Select(g => new ModelGeneration
+ {
+ CarModelId = carModelIdMap[g.CarModelId],
+ ProductionYear = g.ProductionYear,
+ EngineVolumeLiters = g.EngineVolumeLiters,
+ TransmissionType = g.TransmissionType,
+ HourlyRate = g.HourlyRate
+ }).ToList();
+
+ context.ModelGenerations.AddRange(generationsToInsert);
+ await context.SaveChangesAsync();
+
+ logger.LogInformation("ModelGenerations seeded successfully");
+ }
+
+ ///
+ /// Seeds Cars table if it is empty, ignoring explicit Id values.
+ /// References to ModelGeneration are preserved via mapping.
+ ///
+ private async Task SeedCarsAsync()
+ {
+ if (await context.Cars.AnyAsync())
+ {
+ logger.LogInformation("Cars already exist, skipping seeding");
+ return;
+ }
+
+ logger.LogInformation("Seeding {Count} cars", data.Cars.Count);
+
+ // Словарь старый Id поколения → новый
+ var generationIdMap = (await context.ModelGenerations.ToListAsync())
+ .ToDictionary(
+ mg => data.ModelGenerations.First(ds => ds.ProductionYear == mg.ProductionYear && ds.HourlyRate == mg.HourlyRate).Id,
+ mg => mg.Id);
+
+ var carsToInsert = data.Cars.Select(c => new Car
+ {
+ LicensePlate = c.LicensePlate,
+ Color = c.Color,
+ ModelGenerationId = generationIdMap[c.ModelGenerationId]
+ }).ToList();
+
+ context.Cars.AddRange(carsToInsert);
+ await context.SaveChangesAsync();
+
+ logger.LogInformation("Cars seeded successfully");
+ }
+
+ ///
+ /// Seeds Customers table if it is empty, ignoring explicit Id values.
+ ///
+ private async Task SeedCustomersAsync()
+ {
+ if (await context.Customers.AnyAsync())
+ {
+ logger.LogInformation("Customers already exist, skipping seeding");
+ return;
+ }
+
+ logger.LogInformation("Seeding {Count} customers", data.Customers.Count);
+
+ var customersToInsert = data.Customers.Select(c => new Customer
+ {
+ DriverLicenseNumber = c.DriverLicenseNumber,
+ FullName = c.FullName,
+ DateOfBirth = c.DateOfBirth
+ }).ToList();
+
+ context.Customers.AddRange(customersToInsert);
+ await context.SaveChangesAsync();
+
+ logger.LogInformation("Customers seeded successfully");
+ }
+
+ ///
+ /// Seeds Rentals table if it is empty, ignoring explicit Id values.
+ /// References to Customer and Car are preserved via mapping.
+ ///
+ private async Task SeedRentalsAsync()
+ {
+ if (await context.Rentals.AnyAsync())
+ {
+ logger.LogInformation("Rentals already exist, skipping seeding");
+ return;
+ }
+
+ logger.LogInformation("Seeding {Count} rentals", data.Rentals.Count);
+
+ // Словари для сопоставления
+ var customerIdMap = (await context.Customers.ToListAsync())
+ .ToDictionary(
+ c => data.Customers.First(ds => ds.DriverLicenseNumber == c.DriverLicenseNumber).Id,
+ c => c.Id);
+
+ var carIdMap = (await context.Cars.ToListAsync())
+ .ToDictionary(
+ c => data.Cars.First(ds => ds.LicensePlate == c.LicensePlate).Id,
+ c => c.Id);
+
+ var rentalsToInsert = data.Rentals.Select(r => new Rental
+ {
+ CustomerId = customerIdMap[r.CustomerId],
+ CarId = carIdMap[r.CarId],
+ PickupDateTime = r.PickupDateTime,
+ Hours = r.Hours
+ }).ToList();
+
+ context.Rentals.AddRange(rentalsToInsert);
+ await context.SaveChangesAsync();
+
+ logger.LogInformation("Rentals seeded successfully");
+ }
+
+ ///
+ /// Removes all records from the Rentals table.
+ ///
+ private async Task ClearRentalsAsync()
+ {
+ var count = await context.Rentals.ExecuteDeleteAsync();
+ LogClearResult("rentals", count);
+ }
+
+ ///
+ /// Removes all records from the Cars table.
+ ///
+ private async Task ClearCarsAsync()
+ {
+ var count = await context.Cars.ExecuteDeleteAsync();
+ LogClearResult("cars", count);
+ }
+
+ ///
+ /// Removes all records from the ModelGenerations table.
+ ///
+ private async Task ClearModelGenerationsAsync()
+ {
+ var count = await context.ModelGenerations.ExecuteDeleteAsync();
+ LogClearResult("model generations", count);
+ }
+
+ ///
+ /// Removes all records from the CarModels table.
+ ///
+ private async Task ClearCarModelsAsync()
+ {
+ var count = await context.CarModels.ExecuteDeleteAsync();
+ LogClearResult("car models", count);
+ }
+
+ ///
+ /// Removes all records from the Customers table.
+ ///
+ private async Task ClearCustomersAsync()
+ {
+ var count = await context.Customers.ExecuteDeleteAsync();
+ LogClearResult("customers", count);
+ }
+
+ ///
+ /// Logs the result of a table clearing operation.
+ ///
+ /// Plural name of the entity type that was cleared.
+ /// Number of records that were removed.
+ private void LogClearResult(string entityName, int count)
+ {
+ if (count > 0)
+ {
+ logger.LogInformation("Removed {Count} {EntityName}", count, entityName);
+ }
+ else
+ {
+ logger.LogInformation("No {EntityName} to remove", entityName);
+ }
+ }
+}
\ No newline at end of file
diff --git a/CarRental/CarRental.Infrastructure/Data/Interfaces/IDataSeeder.cs b/CarRental/CarRental.Infrastructure/Data/Interfaces/IDataSeeder.cs
new file mode 100644
index 000000000..d1b299fb6
--- /dev/null
+++ b/CarRental/CarRental.Infrastructure/Data/Interfaces/IDataSeeder.cs
@@ -0,0 +1,22 @@
+namespace CarRental.Infrastructure.Data.Interfaces;
+
+///
+/// Defines the contract for data seeding operations.
+/// Provides methods for initializing and clearing data in the underlying data store.
+///
+public interface IDataSeeder
+{
+ ///
+ /// Seeds the data store with initial test or development data.
+ /// Populates the database with predefined entities for application initialization.
+ ///
+ /// A task representing the asynchronous seeding operation.
+ public Task SeedAsync();
+
+ ///
+ /// Clears all data from the data store.
+ /// Removes all entities to prepare for fresh data initialization or testing scenarios.
+ ///
+ /// A task representing the asynchronous clearing operation.
+ public Task ClearAsync();
+}
diff --git a/CarRental/CarRental.Infrastructure/Migrations/20260222093112_InitialCreate.Designer.cs b/CarRental/CarRental.Infrastructure/Migrations/20260222093112_InitialCreate.Designer.cs
new file mode 100644
index 000000000..5b6730710
--- /dev/null
+++ b/CarRental/CarRental.Infrastructure/Migrations/20260222093112_InitialCreate.Designer.cs
@@ -0,0 +1,223 @@
+//
+using System;
+using CarRental.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace CarRental.Infrastructure.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20260222093112_InitialCreate")]
+ partial class InitialCreate
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "9.0.10")
+ .HasAnnotation("Proxies:ChangeTracking", false)
+ .HasAnnotation("Proxies:CheckEquality", false)
+ .HasAnnotation("Proxies:LazyLoading", true)
+ .HasAnnotation("Relational:MaxIdentifierLength", 128);
+
+ SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
+
+ modelBuilder.Entity("CarRental.Domain.Models.Car", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("Color")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.Property("LicensePlate")
+ .IsRequired()
+ .HasMaxLength(12)
+ .HasColumnType("nvarchar(12)");
+
+ b.Property("ModelGenerationId")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("LicensePlate")
+ .IsUnique();
+
+ b.HasIndex("ModelGenerationId");
+
+ b.ToTable("Cars");
+ });
+
+ modelBuilder.Entity("CarRental.Domain.Models.CarModel", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("BodyType")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("CarClass")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("DriverType")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("SeatingCapacity")
+ .HasColumnType("tinyint");
+
+ b.HasKey("Id");
+
+ b.ToTable("CarModels");
+ });
+
+ modelBuilder.Entity("CarRental.Domain.Models.Customer", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("DateOfBirth")
+ .HasColumnType("date");
+
+ b.Property("DriverLicenseNumber")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("nvarchar(20)");
+
+ b.Property("FullName")
+ .IsRequired()
+ .HasMaxLength(150)
+ .HasColumnType("nvarchar(150)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DriverLicenseNumber")
+ .IsUnique();
+
+ b.ToTable("Customers");
+ });
+
+ modelBuilder.Entity("CarRental.Domain.Models.ModelGeneration", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("CarModelId")
+ .HasColumnType("int");
+
+ b.Property("EngineVolumeLiters")
+ .HasPrecision(4, 1)
+ .HasColumnType("decimal(4,1)");
+
+ b.Property("HourlyRate")
+ .HasPrecision(10, 2)
+ .HasColumnType("decimal(10,2)");
+
+ b.Property("ProductionYear")
+ .HasColumnType("int");
+
+ b.Property("TransmissionType")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CarModelId");
+
+ b.ToTable("ModelGenerations");
+ });
+
+ modelBuilder.Entity("CarRental.Domain.Models.Rental", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("CarId")
+ .HasColumnType("int");
+
+ b.Property("CustomerId")
+ .HasColumnType("int");
+
+ b.Property("Hours")
+ .HasColumnType("int");
+
+ b.Property("PickupDateTime")
+ .HasColumnType("datetime2");
+
+ b.HasKey("Id");
+
+ b.HasIndex("PickupDateTime");
+
+ b.HasIndex("CarId", "PickupDateTime");
+
+ b.HasIndex("CustomerId", "PickupDateTime");
+
+ b.ToTable("Rentals");
+ });
+
+ modelBuilder.Entity("CarRental.Domain.Models.Car", b =>
+ {
+ b.HasOne("CarRental.Domain.Models.ModelGeneration", null)
+ .WithMany()
+ .HasForeignKey("ModelGenerationId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("CarRental.Domain.Models.ModelGeneration", b =>
+ {
+ b.HasOne("CarRental.Domain.Models.CarModel", null)
+ .WithMany()
+ .HasForeignKey("CarModelId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("CarRental.Domain.Models.Rental", b =>
+ {
+ b.HasOne("CarRental.Domain.Models.Car", null)
+ .WithMany()
+ .HasForeignKey("CarId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("CarRental.Domain.Models.Customer", null)
+ .WithMany()
+ .HasForeignKey("CustomerId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/CarRental/CarRental.Infrastructure/Migrations/20260222093112_InitialCreate.cs b/CarRental/CarRental.Infrastructure/Migrations/20260222093112_InitialCreate.cs
new file mode 100644
index 000000000..c5520fadb
--- /dev/null
+++ b/CarRental/CarRental.Infrastructure/Migrations/20260222093112_InitialCreate.cs
@@ -0,0 +1,173 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace CarRental.Infrastructure.Migrations;
+
+///
+public partial class InitialCreate : Migration
+{
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "CarModels",
+ columns: table => new
+ {
+ Id = table.Column(type: "int", nullable: false)
+ .Annotation("SqlServer:Identity", "1, 1"),
+ Name = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false),
+ DriverType = table.Column(type: "nvarchar(max)", nullable: false),
+ SeatingCapacity = table.Column(type: "tinyint", nullable: false),
+ BodyType = table.Column(type: "nvarchar(max)", nullable: false),
+ CarClass = table.Column(type: "nvarchar(max)", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_CarModels", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "Customers",
+ columns: table => new
+ {
+ Id = table.Column(type: "int", nullable: false)
+ .Annotation("SqlServer:Identity", "1, 1"),
+ DriverLicenseNumber = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false),
+ FullName = table.Column(type: "nvarchar(150)", maxLength: 150, nullable: false),
+ DateOfBirth = table.Column(type: "date", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Customers", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "ModelGenerations",
+ columns: table => new
+ {
+ Id = table.Column(type: "int", nullable: false)
+ .Annotation("SqlServer:Identity", "1, 1"),
+ ProductionYear = table.Column(type: "int", nullable: false),
+ EngineVolumeLiters = table.Column(type: "decimal(4,1)", precision: 4, scale: 1, nullable: false),
+ TransmissionType = table.Column(type: "nvarchar(max)", nullable: false),
+ HourlyRate = table.Column(type: "decimal(10,2)", precision: 10, scale: 2, nullable: false),
+ CarModelId = table.Column(type: "int", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_ModelGenerations", x => x.Id);
+ table.ForeignKey(
+ name: "FK_ModelGenerations_CarModels_CarModelId",
+ column: x => x.CarModelId,
+ principalTable: "CarModels",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Restrict);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "Cars",
+ columns: table => new
+ {
+ Id = table.Column(type: "int", nullable: false)
+ .Annotation("SqlServer:Identity", "1, 1"),
+ LicensePlate = table.Column(type: "nvarchar(12)", maxLength: 12, nullable: false),
+ Color = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: false),
+ ModelGenerationId = table.Column(type: "int", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Cars", x => x.Id);
+ table.ForeignKey(
+ name: "FK_Cars_ModelGenerations_ModelGenerationId",
+ column: x => x.ModelGenerationId,
+ principalTable: "ModelGenerations",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Restrict);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "Rentals",
+ columns: table => new
+ {
+ Id = table.Column(type: "int", nullable: false)
+ .Annotation("SqlServer:Identity", "1, 1"),
+ CustomerId = table.Column(type: "int", nullable: false),
+ CarId = table.Column(type: "int", nullable: false),
+ PickupDateTime = table.Column(type: "datetime2", nullable: false),
+ Hours = table.Column(type: "int", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Rentals", x => x.Id);
+ table.ForeignKey(
+ name: "FK_Rentals_Cars_CarId",
+ column: x => x.CarId,
+ principalTable: "Cars",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Restrict);
+ table.ForeignKey(
+ name: "FK_Rentals_Customers_CustomerId",
+ column: x => x.CustomerId,
+ principalTable: "Customers",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Restrict);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Cars_LicensePlate",
+ table: "Cars",
+ column: "LicensePlate",
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Cars_ModelGenerationId",
+ table: "Cars",
+ column: "ModelGenerationId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Customers_DriverLicenseNumber",
+ table: "Customers",
+ column: "DriverLicenseNumber",
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_ModelGenerations_CarModelId",
+ table: "ModelGenerations",
+ column: "CarModelId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Rentals_CarId_PickupDateTime",
+ table: "Rentals",
+ columns: new[] { "CarId", "PickupDateTime" });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Rentals_CustomerId_PickupDateTime",
+ table: "Rentals",
+ columns: new[] { "CustomerId", "PickupDateTime" });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Rentals_PickupDateTime",
+ table: "Rentals",
+ column: "PickupDateTime");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "Rentals");
+
+ migrationBuilder.DropTable(
+ name: "Cars");
+
+ migrationBuilder.DropTable(
+ name: "Customers");
+
+ migrationBuilder.DropTable(
+ name: "ModelGenerations");
+
+ migrationBuilder.DropTable(
+ name: "CarModels");
+ }
+}
diff --git a/CarRental/CarRental.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/CarRental/CarRental.Infrastructure/Migrations/AppDbContextModelSnapshot.cs
new file mode 100644
index 000000000..1a7794d9e
--- /dev/null
+++ b/CarRental/CarRental.Infrastructure/Migrations/AppDbContextModelSnapshot.cs
@@ -0,0 +1,220 @@
+//
+using System;
+using CarRental.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace CarRental.Infrastructure.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ partial class AppDbContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "9.0.10")
+ .HasAnnotation("Proxies:ChangeTracking", false)
+ .HasAnnotation("Proxies:CheckEquality", false)
+ .HasAnnotation("Proxies:LazyLoading", true)
+ .HasAnnotation("Relational:MaxIdentifierLength", 128);
+
+ SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
+
+ modelBuilder.Entity("CarRental.Domain.Models.Car", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("Color")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("nvarchar(50)");
+
+ b.Property("LicensePlate")
+ .IsRequired()
+ .HasMaxLength(12)
+ .HasColumnType("nvarchar(12)");
+
+ b.Property("ModelGenerationId")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("LicensePlate")
+ .IsUnique();
+
+ b.HasIndex("ModelGenerationId");
+
+ b.ToTable("Cars");
+ });
+
+ modelBuilder.Entity("CarRental.Domain.Models.CarModel", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("BodyType")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("CarClass")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("DriverType")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("SeatingCapacity")
+ .HasColumnType("tinyint");
+
+ b.HasKey("Id");
+
+ b.ToTable("CarModels");
+ });
+
+ modelBuilder.Entity("CarRental.Domain.Models.Customer", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("DateOfBirth")
+ .HasColumnType("date");
+
+ b.Property("DriverLicenseNumber")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("nvarchar(20)");
+
+ b.Property("FullName")
+ .IsRequired()
+ .HasMaxLength(150)
+ .HasColumnType("nvarchar(150)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DriverLicenseNumber")
+ .IsUnique();
+
+ b.ToTable("Customers");
+ });
+
+ modelBuilder.Entity("CarRental.Domain.Models.ModelGeneration", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("CarModelId")
+ .HasColumnType("int");
+
+ b.Property("EngineVolumeLiters")
+ .HasPrecision(4, 1)
+ .HasColumnType("decimal(4,1)");
+
+ b.Property("HourlyRate")
+ .HasPrecision(10, 2)
+ .HasColumnType("decimal(10,2)");
+
+ b.Property("ProductionYear")
+ .HasColumnType("int");
+
+ b.Property("TransmissionType")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CarModelId");
+
+ b.ToTable("ModelGenerations");
+ });
+
+ modelBuilder.Entity("CarRental.Domain.Models.Rental", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("CarId")
+ .HasColumnType("int");
+
+ b.Property("CustomerId")
+ .HasColumnType("int");
+
+ b.Property("Hours")
+ .HasColumnType("int");
+
+ b.Property("PickupDateTime")
+ .HasColumnType("datetime2");
+
+ b.HasKey("Id");
+
+ b.HasIndex("PickupDateTime");
+
+ b.HasIndex("CarId", "PickupDateTime");
+
+ b.HasIndex("CustomerId", "PickupDateTime");
+
+ b.ToTable("Rentals");
+ });
+
+ modelBuilder.Entity("CarRental.Domain.Models.Car", b =>
+ {
+ b.HasOne("CarRental.Domain.Models.ModelGeneration", null)
+ .WithMany()
+ .HasForeignKey("ModelGenerationId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("CarRental.Domain.Models.ModelGeneration", b =>
+ {
+ b.HasOne("CarRental.Domain.Models.CarModel", null)
+ .WithMany()
+ .HasForeignKey("CarModelId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("CarRental.Domain.Models.Rental", b =>
+ {
+ b.HasOne("CarRental.Domain.Models.Car", null)
+ .WithMany()
+ .HasForeignKey("CarId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("CarRental.Domain.Models.Customer", null)
+ .WithMany()
+ .HasForeignKey("CustomerId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/CarRental/CarRental.Infrastructure/Persistence/AppDbContext.cs b/CarRental/CarRental.Infrastructure/Persistence/AppDbContext.cs
new file mode 100644
index 000000000..7bd41fe52
--- /dev/null
+++ b/CarRental/CarRental.Infrastructure/Persistence/AppDbContext.cs
@@ -0,0 +1,130 @@
+using CarRental.Domain.Models;
+using Microsoft.EntityFrameworkCore;
+
+namespace CarRental.Infrastructure.Persistence;
+
+///
+/// Entity Framework database context for the Car Rental application.
+/// Represents a session with the database and provides access to entity sets.
+///
+public class AppDbContext : DbContext
+{
+ ///
+ /// Initializes a new instance of the AppDbContext class.
+ ///
+ /// The options to be used by the DbContext.
+ public AppDbContext(DbContextOptions options) : base(options) { }
+
+ ///
+ /// Gets or sets the Cars entity set.
+ ///
+ public DbSet Cars { get; set; } = null!;
+
+ ///
+ /// Gets or sets the CarModels entity set.
+ ///
+ public DbSet CarModels { get; set; } = null!;
+
+ ///
+ /// Gets or sets the ModelGenerations entity set.
+ ///
+ public DbSet ModelGenerations { get; set; } = null!;
+
+ ///
+ /// Gets or sets the Customers entity set.
+ ///
+ public DbSet Customers { get; set; } = null!;
+
+ ///
+ /// Gets or sets the Rentals entity set.
+ ///
+ public DbSet Rentals { get; set; } = null!;
+
+ ///
+ /// Configures the model that was discovered by convention from the entity types
+ /// exposed in DbSet properties on the derived context.
+ ///
+ /// The builder being used to construct the model for this context.
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ base.OnModelCreating(modelBuilder);
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(e => e.Id);
+ entity.Property(e => e.Id).ValueGeneratedOnAdd();
+ entity.Property(e => e.DriverLicenseNumber).IsRequired().HasMaxLength(20);
+ entity.Property(e => e.FullName).IsRequired().HasMaxLength(150);
+ entity.Property(e => e.DateOfBirth).IsRequired();
+
+ entity.HasIndex(e => e.DriverLicenseNumber).IsUnique();
+ });
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(e => e.Id);
+ entity.Property(e => e.Id).ValueGeneratedOnAdd();
+ entity.Property(e => e.Name).IsRequired().HasMaxLength(100);
+ entity.Property(e => e.DriverType).IsRequired().HasConversion();
+ entity.Property(e => e.SeatingCapacity).IsRequired();
+ entity.Property(e => e.BodyType).IsRequired().HasConversion();
+ entity.Property(e => e.CarClass).IsRequired().HasConversion();
+ });
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(e => e.Id);
+ entity.Property(e => e.Id).ValueGeneratedOnAdd();
+ entity.Property(e => e.ProductionYear).IsRequired();
+ entity.Property(e => e.EngineVolumeLiters).IsRequired().HasPrecision(4, 1);
+ entity.Property(e => e.TransmissionType).IsRequired().HasConversion();
+ entity.Property(e => e.HourlyRate).IsRequired().HasPrecision(10, 2);
+ entity.Property(e => e.CarModelId).IsRequired();
+
+ entity.HasOne()
+ .WithMany()
+ .HasForeignKey(e => e.CarModelId)
+ .OnDelete(DeleteBehavior.Restrict);
+ });
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(e => e.Id);
+ entity.Property(e => e.Id).ValueGeneratedOnAdd();
+ entity.Property(e => e.LicensePlate).IsRequired().HasMaxLength(12);
+ entity.Property(e => e.Color).IsRequired().HasMaxLength(50);
+ entity.Property(e => e.ModelGenerationId).IsRequired();
+
+ entity.HasOne()
+ .WithMany()
+ .HasForeignKey(e => e.ModelGenerationId)
+ .OnDelete(DeleteBehavior.Restrict);
+
+ entity.HasIndex(e => e.LicensePlate).IsUnique();
+ });
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(e => e.Id);
+ entity.Property(e => e.Id).ValueGeneratedOnAdd();
+ entity.Property(e => e.CustomerId).IsRequired();
+ entity.Property(e => e.CarId).IsRequired();
+ entity.Property(e => e.PickupDateTime).IsRequired();
+ entity.Property(e => e.Hours).IsRequired();
+
+ entity.HasOne()
+ .WithMany()
+ .HasForeignKey(e => e.CustomerId)
+ .OnDelete(DeleteBehavior.Restrict);
+
+ entity.HasOne()
+ .WithMany()
+ .HasForeignKey(e => e.CarId)
+ .OnDelete(DeleteBehavior.Restrict);
+
+ entity.HasIndex(e => e.PickupDateTime);
+ entity.HasIndex(e => new { e.CarId, e.PickupDateTime });
+ entity.HasIndex(e => new { e.CustomerId, e.PickupDateTime });
+ });
+ }
+}
\ No newline at end of file
diff --git a/CarRental/CarRental.Infrastructure/Repositories/Interfaces/IRepository.cs b/CarRental/CarRental.Infrastructure/Repositories/Interfaces/IRepository.cs
new file mode 100644
index 000000000..10be55257
--- /dev/null
+++ b/CarRental/CarRental.Infrastructure/Repositories/Interfaces/IRepository.cs
@@ -0,0 +1,43 @@
+namespace CarRental.Infrastructure.Repositories.Interfaces;
+
+/// Generic repository interface for performing CRUD operations on domain
+/// entities.
+/// Provides basic create, read, update, and delete functionality for all entity types.
+///
+/// The type of entity this repository works with, must inherit from Model.
+public interface IRepository
+{
+ ///
+ /// Creates a new entity in the repository.
+ ///
+ /// The entity to create.
+ /// The ID of the newly created entity.
+ public Task CreateAsync(T entity);
+
+ ///
+ /// Retrieves all entities from the repository.
+ ///
+ /// A collection of all entities.
+ public Task> GetAsync();
+
+ ///
+ /// Retrieves a specific entity by its unique identifier.
+ ///
+ /// The ID of the entity to retrieve.
+ /// The entity if found; otherwise, null.
+ public Task GetAsync(int id);
+
+ ///
+ /// Updates an existing entity in the repository.
+ ///
+ /// The entity with updated data.
+ /// The updated entity if successful; otherwise, null.
+ public Task UpdateAsync(T entity);
+
+ ///
+ /// Deletes an entity from the repository by its unique identifier.
+ ///
+ /// The ID of the entity to delete.
+ /// True if the entity was successfully deleted; otherwise, false.
+ public Task DeleteAsync(int id);
+}
\ No newline at end of file
diff --git a/CarRental/CarRental.Infrastructure/Repositories/Repository.cs b/CarRental/CarRental.Infrastructure/Repositories/Repository.cs
new file mode 100644
index 000000000..a3b352030
--- /dev/null
+++ b/CarRental/CarRental.Infrastructure/Repositories/Repository.cs
@@ -0,0 +1,72 @@
+using CarRental.Domain.Models.Abstract;
+using CarRental.Infrastructure.Repositories.Interfaces;
+using CarRental.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+
+namespace CarRental.Infrastructure.Repositories;
+
+///
+/// Generic repository implementation using Entity Framework Core for data access.
+/// Provides CRUD operations for domain entities with database persistence.
+///
+/// The type of entity this repository works with, must inherit from Model.
+public class Repository(AppDbContext context) : IRepository where T : Model
+{
+
+ ///
+ /// Creates a new entity in the database.
+ ///
+ /// The entity to create.
+ /// The ID of the newly created entity.
+ public async Task CreateAsync(T entity)
+ {
+ context.Set().Add(entity);
+ await context.SaveChangesAsync();
+ return entity.Id;
+ }
+
+ ///
+ /// Retrieves all entities from the database.
+ ///
+ /// A collection of all entities.
+ public async Task> GetAsync()
+ {
+ return await context.Set().ToListAsync();
+ }
+
+ ///
+ /// Retrieves a specific entity by its unique identifier.
+ ///
+ /// The ID of the entity to retrieve.
+ /// The entity if found; otherwise, null.
+ public async Task GetAsync(int id)
+ {
+ return await context.Set().FindAsync(id);
+ }
+
+ ///