diff --git a/.github/workflows/dotnet_tests.yml b/.github/workflows/dotnet_tests.yml
new file mode 100644
index 000000000..8a6b671ce
--- /dev/null
+++ b/.github/workflows/dotnet_tests.yml
@@ -0,0 +1,31 @@
+name: .NET Tests
+
+on:
+ push:
+ branches: ["main", "master"]
+ pull_request:
+ branches: ["main", "master"]
+ pull_request_target:
+ branches: ["main", "master"]
+
+jobs:
+ test:
+ runs-on: windows-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '8.x'
+
+ - name: Restore dependencies
+ run: dotnet restore Bikes.sln
+
+ - name: Build
+ run: dotnet build Bikes.sln --no-restore --configuration Release
+
+ - name: Run tests
+ run: dotnet test Bikes.Tests/Bikes.Tests.csproj --no-build --configuration Release --verbosity normal
\ No newline at end of file
diff --git a/Bikes.Api.Host/Bikes.Api.Host.csproj b/Bikes.Api.Host/Bikes.Api.Host.csproj
new file mode 100644
index 000000000..332a054c9
--- /dev/null
+++ b/Bikes.Api.Host/Bikes.Api.Host.csproj
@@ -0,0 +1,22 @@
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Bikes.Api.Host/Bikes.Api.Host.http b/Bikes.Api.Host/Bikes.Api.Host.http
new file mode 100644
index 000000000..00666aa4a
--- /dev/null
+++ b/Bikes.Api.Host/Bikes.Api.Host.http
@@ -0,0 +1,6 @@
+@Bikes.Api.Host_HostAddress = http://localhost:5145
+
+GET {{Bikes.Api.Host_HostAddress}}/weatherforecast/
+Accept: application/json
+
+###
diff --git a/Bikes.Api.Host/Controllers/AnalyticsController.cs b/Bikes.Api.Host/Controllers/AnalyticsController.cs
new file mode 100644
index 000000000..aabff1312
--- /dev/null
+++ b/Bikes.Api.Host/Controllers/AnalyticsController.cs
@@ -0,0 +1,127 @@
+using Bikes.Application.Contracts.Analytics;
+using Bikes.Application.Contracts.Bikes;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Bikes.Api.Host.Controllers;
+
+///
+/// Controller for rental analytics
+///
+[ApiController]
+[Route("api/[controller]")]
+public class AnalyticsController(IAnalyticsService analyticsService) : ControllerBase
+{
+ ///
+ /// Get all sport bikes
+ ///
+ [HttpGet("sport-bikes")]
+ [ProducesResponseType(typeof(List), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status500InternalServerError)]
+ public ActionResult> GetSportBikes()
+ {
+ try
+ {
+ var sportBikes = analyticsService.GetSportBikes();
+ return Ok(sportBikes);
+ }
+ catch (Exception ex)
+ {
+ return StatusCode(500, $"Internal server error: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Get top 5 models by profit
+ ///
+ [HttpGet("top-models-by-profit")]
+ [ProducesResponseType(typeof(List), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status500InternalServerError)]
+ public ActionResult> GetTopModelsByProfit()
+ {
+ try
+ {
+ var topModels = analyticsService.GetTop5ModelsByProfit();
+ return Ok(topModels);
+ }
+ catch (Exception ex)
+ {
+ return StatusCode(500, $"Internal server error: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Get top 5 models by rental duration
+ ///
+ [HttpGet("top-models-by-duration")]
+ [ProducesResponseType(typeof(List), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status500InternalServerError)]
+ public ActionResult> GetTopModelsByDuration()
+ {
+ try
+ {
+ var topModels = analyticsService.GetTop5ModelsByRentalDuration();
+ return Ok(topModels);
+ }
+ catch (Exception ex)
+ {
+ return StatusCode(500, $"Internal server error: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Get rental statistics
+ ///
+ [HttpGet("rental-statistics")]
+ [ProducesResponseType(typeof(RentalStatistics), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status500InternalServerError)]
+ public ActionResult GetRentalStatistics()
+ {
+ try
+ {
+ var statistics = analyticsService.GetRentalStatistics();
+ return Ok(statistics);
+ }
+ catch (Exception ex)
+ {
+ return StatusCode(500, $"Internal server error: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Get total rental time by bike type
+ ///
+ [HttpGet("rental-time-by-type")]
+ [ProducesResponseType(typeof(Dictionary), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status500InternalServerError)]
+ public ActionResult> GetRentalTimeByType()
+ {
+ try
+ {
+ var rentalTime = analyticsService.GetTotalRentalTimeByBikeType();
+ return Ok(rentalTime);
+ }
+ catch (Exception ex)
+ {
+ return StatusCode(500, $"Internal server error: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Get top renters by rental count
+ ///
+ [HttpGet("top-renters")]
+ [ProducesResponseType(typeof(List), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status500InternalServerError)]
+ public ActionResult> GetTopRenters()
+ {
+ try
+ {
+ var topRenters = analyticsService.GetTopRentersByRentalCount();
+ return Ok(topRenters);
+ }
+ catch (Exception ex)
+ {
+ return StatusCode(500, $"Internal server error: {ex.Message}");
+ }
+ }
+}
\ No newline at end of file
diff --git a/Bikes.Api.Host/Controllers/BikeModelsController.cs b/Bikes.Api.Host/Controllers/BikeModelsController.cs
new file mode 100644
index 000000000..167b7604d
--- /dev/null
+++ b/Bikes.Api.Host/Controllers/BikeModelsController.cs
@@ -0,0 +1,16 @@
+using Bikes.Application.Contracts;
+using Bikes.Application.Contracts.Models;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Bikes.Api.Host.Controllers;
+
+///
+/// Controller for bike model management
+///
+[ApiController]
+[Route("api/[controller]")]
+public class BikeModelsController(IBikeModelService bikeModelService)
+ : CrudControllerBase
+{
+ protected override IApplicationService Service => bikeModelService;
+}
\ No newline at end of file
diff --git a/Bikes.Api.Host/Controllers/BikesController.cs b/Bikes.Api.Host/Controllers/BikesController.cs
new file mode 100644
index 000000000..c2f349706
--- /dev/null
+++ b/Bikes.Api.Host/Controllers/BikesController.cs
@@ -0,0 +1,16 @@
+using Bikes.Application.Contracts;
+using Bikes.Application.Contracts.Bikes;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Bikes.Api.Host.Controllers;
+
+///
+/// Controller for bike management
+///
+[ApiController]
+[Route("api/[controller]")]
+public class BikesController(IBikeService bikeService)
+ : CrudControllerBase
+{
+ protected override IApplicationService Service => bikeService;
+}
\ No newline at end of file
diff --git a/Bikes.Api.Host/Controllers/CrudControllerBase.cs b/Bikes.Api.Host/Controllers/CrudControllerBase.cs
new file mode 100644
index 000000000..28a402df4
--- /dev/null
+++ b/Bikes.Api.Host/Controllers/CrudControllerBase.cs
@@ -0,0 +1,145 @@
+using Bikes.Application.Contracts;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Bikes.Api.Host.Controllers;
+
+///
+/// Base controller for CRUD operations
+///
+/// DTO type
+/// Create/Update DTO type
+[ApiController]
+[Route("api/[controller]")]
+public abstract class CrudControllerBase : ControllerBase
+ where TDto : class
+ where TCreateUpdateDto : class
+{
+ protected abstract IApplicationService Service { get; }
+
+ ///
+ /// Get all entities
+ ///
+ [HttpGet]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status500InternalServerError)]
+ public virtual ActionResult> GetAll()
+ {
+ try
+ {
+ var entities = Service.GetAll();
+ return Ok(entities);
+ }
+ catch (Exception ex)
+ {
+ return StatusCode(500, $"Internal server error: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Get entity by id
+ ///
+ [HttpGet("{id}")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status500InternalServerError)]
+ public virtual ActionResult GetById(int id)
+ {
+ try
+ {
+ var entity = Service.GetById(id);
+ return entity == null ? NotFound() : Ok(entity);
+ }
+ catch (Exception ex)
+ {
+ return StatusCode(500, $"Internal server error: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Create new entity
+ ///
+ [HttpPost]
+ [ProducesResponseType(StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status500InternalServerError)]
+ public virtual ActionResult Create([FromBody] TCreateUpdateDto request)
+ {
+ try
+ {
+ if (!ModelState.IsValid)
+ {
+ return BadRequest(ModelState);
+ }
+
+ var entity = Service.Create(request);
+ return CreatedAtAction(nameof(GetById), new { id = GetEntityId(entity) }, entity);
+ }
+ catch (InvalidOperationException ex)
+ {
+ return BadRequest(ex.Message);
+ }
+ catch (Exception ex)
+ {
+ return StatusCode(500, $"Internal server error: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Update entity
+ ///
+ [HttpPut("{id}")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status500InternalServerError)]
+ public virtual ActionResult Update(int id, [FromBody] TCreateUpdateDto request)
+ {
+ try
+ {
+ if (!ModelState.IsValid)
+ {
+ return BadRequest(ModelState);
+ }
+
+ var entity = Service.Update(id, request);
+ return entity == null ? NotFound() : Ok(entity);
+ }
+ catch (InvalidOperationException ex)
+ {
+ return BadRequest(ex.Message);
+ }
+ catch (Exception ex)
+ {
+ return StatusCode(500, $"Internal server error: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Delete entity
+ ///
+ [HttpDelete("{id}")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status500InternalServerError)]
+ public virtual ActionResult Delete(int id)
+ {
+ try
+ {
+ var result = Service.Delete(id);
+ return result ? NoContent() : NotFound();
+ }
+ catch (Exception ex)
+ {
+ return StatusCode(500, $"Internal server error: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Extract entity ID for CreatedAtAction
+ ///
+ private static int GetEntityId(TDto entity)
+ {
+ var idProperty = typeof(TDto).GetProperty("Id");
+ return idProperty != null ? (int)idProperty.GetValue(entity)! : 0;
+ }
+}
\ No newline at end of file
diff --git a/Bikes.Api.Host/Controllers/RentersController.cs b/Bikes.Api.Host/Controllers/RentersController.cs
new file mode 100644
index 000000000..4a12de855
--- /dev/null
+++ b/Bikes.Api.Host/Controllers/RentersController.cs
@@ -0,0 +1,16 @@
+using Bikes.Application.Contracts;
+using Bikes.Application.Contracts.Renters;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Bikes.Api.Host.Controllers;
+
+///
+/// Controller for renter management
+///
+[ApiController]
+[Route("api/[controller]")]
+public class RentersController(IRenterService renterService)
+ : CrudControllerBase
+{
+ protected override IApplicationService Service => renterService;
+}
\ No newline at end of file
diff --git a/Bikes.Api.Host/Controllers/RentsController.cs b/Bikes.Api.Host/Controllers/RentsController.cs
new file mode 100644
index 000000000..8ed05ef61
--- /dev/null
+++ b/Bikes.Api.Host/Controllers/RentsController.cs
@@ -0,0 +1,16 @@
+using Bikes.Application.Contracts;
+using Bikes.Application.Contracts.Rents;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Bikes.Api.Host.Controllers;
+
+///
+/// Controller for rent management
+///
+[ApiController]
+[Route("api/[controller]")]
+public class RentsController(IRentService rentService)
+ : CrudControllerBase
+{
+ protected override IApplicationService Service => rentService;
+}
\ No newline at end of file
diff --git a/Bikes.Api.Host/Program.cs b/Bikes.Api.Host/Program.cs
new file mode 100644
index 000000000..14b3b6434
--- /dev/null
+++ b/Bikes.Api.Host/Program.cs
@@ -0,0 +1,69 @@
+using Bikes.Application.Contracts.Analytics;
+using Bikes.Application.Contracts.Bikes;
+using Bikes.Application.Contracts.Models;
+using Bikes.Application.Contracts.Renters;
+using Bikes.Application.Contracts.Rents;
+using Bikes.Application.Services;
+using Bikes.Domain.Repositories;
+using Bikes.Infrastructure.EfCore;
+using Bikes.ServiceDefaults;
+using Microsoft.EntityFrameworkCore;
+
+var builder = WebApplication.CreateBuilder(args);
+builder.AddServiceDefaults();
+
+builder.Services.AddControllers();
+builder.Services.AddEndpointsApiExplorer();
+builder.Services.AddSwaggerGen();
+
+builder.Services.AddCors(options =>
+{
+ options.AddDefaultPolicy(
+ policy =>
+ {
+ policy.AllowAnyOrigin()
+ .AllowAnyMethod()
+ .AllowAnyHeader();
+ });
+});
+
+builder.Services.AddDbContext(options =>
+{
+ options.UseNpgsql(builder.Configuration.GetConnectionString("bikes-db"));
+ options.EnableSensitiveDataLogging();
+ options.LogTo(Console.WriteLine, LogLevel.Information);
+});
+
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+
+var app = builder.Build();
+
+app.UseCors();
+
+if (app.Environment.IsDevelopment())
+{
+ app.UseSwagger();
+ app.UseSwaggerUI();
+}
+
+app.UseAuthorization();
+app.MapDefaultEndpoints();
+app.MapControllers();
+
+using (var scope = app.Services.CreateScope())
+{
+ var context = scope.ServiceProvider.GetRequiredService();
+ context.Database.Migrate();
+ context.Seed();
+}
+
+app.Run();
\ No newline at end of file
diff --git a/Bikes.Api.Host/Properties/launchSettings.json b/Bikes.Api.Host/Properties/launchSettings.json
new file mode 100644
index 000000000..d339d863d
--- /dev/null
+++ b/Bikes.Api.Host/Properties/launchSettings.json
@@ -0,0 +1,24 @@
+{
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "launchUrl": "swagger",
+ "applicationUrl": "http://localhost:5186",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "launchUrl": "swagger",
+ "applicationUrl": "https://localhost:7186;http://localhost:5186",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Bikes.Api.Host/appsettings.Development.json b/Bikes.Api.Host/appsettings.Development.json
new file mode 100644
index 000000000..0c208ae91
--- /dev/null
+++ b/Bikes.Api.Host/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/Bikes.Api.Host/appsettings.json b/Bikes.Api.Host/appsettings.json
new file mode 100644
index 000000000..bf32e9174
--- /dev/null
+++ b/Bikes.Api.Host/appsettings.json
@@ -0,0 +1,12 @@
+{
+ "ConnectionStrings": {
+ "DefaultConnection": "Host=localhost;Port=5432;Database=bikes_db;Username=postgres;Password=postgres123"
+ },
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
\ No newline at end of file
diff --git a/Bikes.AppHost/AppHost.cs b/Bikes.AppHost/AppHost.cs
new file mode 100644
index 000000000..e40704eab
--- /dev/null
+++ b/Bikes.AppHost/AppHost.cs
@@ -0,0 +1,43 @@
+using Microsoft.Extensions.Configuration;
+
+var builder = DistributedApplication.CreateBuilder(args);
+
+var postgres = builder.AddPostgres("postgres");
+var bikesDb = postgres.AddDatabase("bikes-db");
+
+var api = builder.AddProject("bikes-api")
+ .WithReference(bikesDb)
+ .WaitFor(bikesDb);
+
+var kafka = builder.AddKafka("bikes-kafka")
+ .WithKafkaUI();
+
+var generatorSettings = builder.Configuration.GetSection("Generator");
+var batchSize = generatorSettings.GetValue("BatchSize", 10);
+var payloadLimit = generatorSettings.GetValue("PayloadLimit", 100);
+var waitTime = generatorSettings.GetValue("WaitTime", 10000);
+
+var kafkaSettings = builder.Configuration.GetSection("Kafka");
+var topic = kafkaSettings["Topic"] ?? "bike-rents";
+var groupId = kafkaSettings["GroupId"] ?? "bikes-consumer-group";
+
+var consumer = builder.AddProject("bikes-consumer")
+ .WithReference(kafka)
+ .WithReference(bikesDb)
+ .WaitFor(kafka)
+ .WaitFor(bikesDb)
+ .WithEnvironment("Kafka__Topic", topic)
+ .WithEnvironment("Kafka__GroupId", groupId)
+ .WithEnvironment("ConnectionStrings__bikes-db", bikesDb);
+
+_ = builder.AddProject("bikes-generator")
+ .WithReference(kafka)
+ .WaitFor(kafka)
+ .WaitFor(api)
+ .WaitFor(consumer)
+ .WithEnvironment("Kafka__Topic", topic)
+ .WithEnvironment("Generator__BatchSize", batchSize.ToString())
+ .WithEnvironment("Generator__PayloadLimit", payloadLimit.ToString())
+ .WithEnvironment("Generator__WaitTime", waitTime.ToString());
+
+builder.Build().Run();
\ No newline at end of file
diff --git a/Bikes.AppHost/Bikes.AppHost.csproj b/Bikes.AppHost/Bikes.AppHost.csproj
new file mode 100644
index 000000000..0d24da477
--- /dev/null
+++ b/Bikes.AppHost/Bikes.AppHost.csproj
@@ -0,0 +1,26 @@
+
+
+
+
+ Exe
+ net8.0
+ enable
+ enable
+ true
+ aspire-host-id
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Bikes.AppHost/Properties/launchSettings.json b/Bikes.AppHost/Properties/launchSettings.json
new file mode 100644
index 000000000..eac85cbcb
--- /dev/null
+++ b/Bikes.AppHost/Properties/launchSettings.json
@@ -0,0 +1,29 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:17150;http://localhost:15096",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21165",
+ "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22192"
+ }
+ },
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:15096",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19260",
+ "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20243"
+ }
+ }
+ }
+}
diff --git a/Bikes.AppHost/appsettings.json b/Bikes.AppHost/appsettings.json
new file mode 100644
index 000000000..833239c93
--- /dev/null
+++ b/Bikes.AppHost/appsettings.json
@@ -0,0 +1,21 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.Hosting.Lifetime": "Information"
+ }
+ },
+ "ConnectionStrings": {
+ "bikes-kafka": "localhost:51251",
+ "bikes-db": "Host=localhost;Port=5432;Database=bikes;Username=postgres;Password=postgres"
+ },
+ "Kafka": {
+ "Topic": "bike-rents",
+ "GroupId": "bikes-consumer-group"
+ },
+ "Generator": {
+ "BatchSize": 10,
+ "PayloadLimit": 100,
+ "WaitTime": 10000
+ }
+}
\ No newline at end of file
diff --git a/Bikes.Application.Contracts/Analytics/AnalyticsDto.cs b/Bikes.Application.Contracts/Analytics/AnalyticsDto.cs
new file mode 100644
index 000000000..9b76610c0
--- /dev/null
+++ b/Bikes.Application.Contracts/Analytics/AnalyticsDto.cs
@@ -0,0 +1,73 @@
+namespace Bikes.Application.Contracts.Analytics;
+
+///
+/// DTO for bike models analytics
+///
+public class BikeModelAnalyticsDto
+{
+ ///
+ /// Model identifier
+ ///
+ public int Id { get; set; }
+
+ ///
+ /// Model name
+ ///
+ public string Name { get; set; } = string.Empty;
+
+ ///
+ /// Bike type
+ ///
+ public string Type { get; set; } = string.Empty;
+
+ ///
+ /// Price per rental hour
+ ///
+ public decimal PricePerHour { get; set; }
+
+ ///
+ /// Total profit
+ ///
+ public decimal TotalProfit { get; set; }
+
+ ///
+ /// Total rental duration
+ ///
+ public int TotalDuration { get; set; }
+}
+
+///
+/// DTO for renters analytics
+///
+public class RenterAnalyticsDto
+{
+ ///
+ /// Renter full name
+ ///
+ public string FullName { get; set; } = string.Empty;
+
+ ///
+ /// Number of rentals
+ ///
+ public int RentalCount { get; set; }
+}
+
+///
+/// Rental statistics
+///
+public record RentalStatistics(
+ ///
+ /// Minimum rental duration
+ ///
+ int MinDuration,
+
+ ///
+ /// Maximum rental duration
+ ///
+ int MaxDuration,
+
+ ///
+ /// Average rental duration
+ ///
+ double AvgDuration
+);
\ No newline at end of file
diff --git a/Bikes.Application.Contracts/Analytics/IAnalyticsService.cs b/Bikes.Application.Contracts/Analytics/IAnalyticsService.cs
new file mode 100644
index 000000000..a18f7b382
--- /dev/null
+++ b/Bikes.Application.Contracts/Analytics/IAnalyticsService.cs
@@ -0,0 +1,39 @@
+using Bikes.Application.Contracts.Bikes;
+
+namespace Bikes.Application.Contracts.Analytics;
+
+///
+/// Service for bike rental analytics
+///
+public interface IAnalyticsService
+{
+ ///
+ /// Get all sport bikes
+ ///
+ public List GetSportBikes();
+
+ ///
+ /// Get top 5 models by profit
+ ///
+ public List GetTop5ModelsByProfit();
+
+ ///
+ /// Get top 5 models by rental duration
+ ///
+ public List GetTop5ModelsByRentalDuration();
+
+ ///
+ /// Get rental statistics
+ ///
+ public RentalStatistics GetRentalStatistics();
+
+ ///
+ /// Get total rental time by bike type
+ ///
+ public Dictionary GetTotalRentalTimeByBikeType();
+
+ ///
+ /// Get top renters by rental count
+ ///
+ public List GetTopRentersByRentalCount();
+}
\ No newline at end of file
diff --git a/Bikes.Application.Contracts/Bikes.Application.Contracts.csproj b/Bikes.Application.Contracts/Bikes.Application.Contracts.csproj
new file mode 100644
index 000000000..d44b7bba6
--- /dev/null
+++ b/Bikes.Application.Contracts/Bikes.Application.Contracts.csproj
@@ -0,0 +1,9 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
\ No newline at end of file
diff --git a/Bikes.Application.Contracts/Bikes/BikeCreateUpdateDto.cs b/Bikes.Application.Contracts/Bikes/BikeCreateUpdateDto.cs
new file mode 100644
index 000000000..b7618bae2
--- /dev/null
+++ b/Bikes.Application.Contracts/Bikes/BikeCreateUpdateDto.cs
@@ -0,0 +1,30 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Bikes.Application.Contracts.Bikes;
+
+///
+/// DTO for creating and updating bikes
+///
+public class BikeCreateUpdateDto
+{
+ ///
+ /// Bike serial number
+ ///
+ [Required(ErrorMessage = "Serial number is required")]
+ [StringLength(50, MinimumLength = 3, ErrorMessage = "Serial number must be between 3 and 50 characters")]
+ public required string SerialNumber { get; set; }
+
+ ///
+ /// Bike model identifier
+ ///
+ [Required(ErrorMessage = "Model ID is required")]
+ [Range(1, int.MaxValue, ErrorMessage = "Model ID must be a positive number")]
+ public required int ModelId { get; set; }
+
+ ///
+ /// Bike color
+ ///
+ [Required(ErrorMessage = "Color is required")]
+ [StringLength(30, MinimumLength = 2, ErrorMessage = "Color must be between 2 and 30 characters")]
+ public required string Color { get; set; }
+}
\ No newline at end of file
diff --git a/Bikes.Application.Contracts/Bikes/BikeDto.cs b/Bikes.Application.Contracts/Bikes/BikeDto.cs
new file mode 100644
index 000000000..87da07606
--- /dev/null
+++ b/Bikes.Application.Contracts/Bikes/BikeDto.cs
@@ -0,0 +1,32 @@
+namespace Bikes.Application.Contracts.Bikes;
+
+///
+/// DTO for bike representation
+///
+public class BikeDto
+{
+ ///
+ /// Bike identifier
+ ///
+ public int Id { get; set; }
+
+ ///
+ /// Bike serial number
+ ///
+ public string SerialNumber { get; set; } = string.Empty;
+
+ ///
+ /// Bike model identifier
+ ///
+ public int ModelId { get; set; }
+
+ ///
+ /// Bike color
+ ///
+ public string Color { get; set; } = string.Empty;
+
+ ///
+ /// Availability status for rental
+ ///
+ public bool IsAvailable { get; set; }
+}
\ No newline at end of file
diff --git a/Bikes.Application.Contracts/Bikes/IBikeService.cs b/Bikes.Application.Contracts/Bikes/IBikeService.cs
new file mode 100644
index 000000000..d880287c9
--- /dev/null
+++ b/Bikes.Application.Contracts/Bikes/IBikeService.cs
@@ -0,0 +1,8 @@
+namespace Bikes.Application.Contracts.Bikes;
+
+///
+/// Service for bike management
+///
+public interface IBikeService : IApplicationService
+{
+}
\ No newline at end of file
diff --git a/Bikes.Application.Contracts/IApplicationService.cs b/Bikes.Application.Contracts/IApplicationService.cs
new file mode 100644
index 000000000..350e0aba6
--- /dev/null
+++ b/Bikes.Application.Contracts/IApplicationService.cs
@@ -0,0 +1,36 @@
+namespace Bikes.Application.Contracts;
+
+///
+/// Base interface for all application services with CRUD operations
+///
+/// DTO type for reading
+/// DTO type for creating and updating
+public interface IApplicationService
+ where TDto : class
+ where TCreateUpdateDto : class
+{
+ ///
+ /// Get all entities
+ ///
+ public List GetAll();
+
+ ///
+ /// Get entity by identifier
+ ///
+ public TDto? GetById(int id);
+
+ ///
+ /// Create new entity
+ ///
+ public TDto Create(TCreateUpdateDto request);
+
+ ///
+ /// Update entity
+ ///
+ public TDto? Update(int id, TCreateUpdateDto request);
+
+ ///
+ /// Delete entity
+ ///
+ public bool Delete(int id);
+}
\ No newline at end of file
diff --git a/Bikes.Application.Contracts/Models/BikeModelCreateUpdateDto.cs b/Bikes.Application.Contracts/Models/BikeModelCreateUpdateDto.cs
new file mode 100644
index 000000000..93173580e
--- /dev/null
+++ b/Bikes.Application.Contracts/Models/BikeModelCreateUpdateDto.cs
@@ -0,0 +1,65 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Bikes.Application.Contracts.Models;
+
+///
+/// DTO for creating and updating bike models
+///
+public class BikeModelCreateUpdateDto
+{
+ ///
+ /// Model name
+ ///
+ [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; }
+
+ ///
+ /// Bike type
+ ///
+ [Required(ErrorMessage = "Bike type is required")]
+ [RegularExpression("^(Mountain|Road|Hybrid|City|Sport)$", ErrorMessage = "Bike type must be Mountain, Road, Hybrid, City, or Sport")]
+ public required string Type { get; set; }
+
+ ///
+ /// Wheel size in inches
+ ///
+ [Required(ErrorMessage = "Wheel size is required")]
+ [Range(10, 30, ErrorMessage = "Wheel size must be between 10 and 30 inches")]
+ public required decimal WheelSize { get; set; }
+
+ ///
+ /// Maximum weight capacity in kg
+ ///
+ [Required(ErrorMessage = "Maximum weight capacity is required")]
+ [Range(50, 300, ErrorMessage = "Maximum weight capacity must be between 50 and 300 kg")]
+ public required decimal MaxWeight { get; set; }
+
+ ///
+ /// Bike weight in kg
+ ///
+ [Required(ErrorMessage = "Bike weight is required")]
+ [Range(5, 50, ErrorMessage = "Bike weight must be between 5 and 50 kg")]
+ public required decimal Weight { get; set; }
+
+ ///
+ /// Brake type
+ ///
+ [Required(ErrorMessage = "Brake type is required")]
+ [RegularExpression("^(Mechanical|Hydraulic|Rim)$", ErrorMessage = "Brake type must be Mechanical, Hydraulic, or Rim")]
+ public required string BrakeType { get; set; }
+
+ ///
+ /// Model year
+ ///
+ [Required(ErrorMessage = "Model year is required")]
+ [Range(2000, 2030, ErrorMessage = "Model year must be between 2000 and 2030")]
+ public required int ModelYear { get; set; }
+
+ ///
+ /// Price per rental hour
+ ///
+ [Required(ErrorMessage = "Price per hour is required")]
+ [Range(0.01, 1000, ErrorMessage = "Price per hour must be between 0.01 and 1000")]
+ public required decimal PricePerHour { get; set; }
+}
\ No newline at end of file
diff --git a/Bikes.Application.Contracts/Models/BikeModelDto.cs b/Bikes.Application.Contracts/Models/BikeModelDto.cs
new file mode 100644
index 000000000..82a96c08a
--- /dev/null
+++ b/Bikes.Application.Contracts/Models/BikeModelDto.cs
@@ -0,0 +1,52 @@
+namespace Bikes.Application.Contracts.Models;
+
+///
+/// DTO for bike model representation
+///
+public class BikeModelDto
+{
+ ///
+ /// Model identifier
+ ///
+ public int Id { get; set; }
+
+ ///
+ /// Model name
+ ///
+ public string Name { get; set; } = string.Empty;
+
+ ///
+ /// Bike type
+ ///
+ public string Type { get; set; } = string.Empty;
+
+ ///
+ /// Wheel size in inches
+ ///
+ public decimal WheelSize { get; set; }
+
+ ///
+ /// Maximum weight capacity in kg
+ ///
+ public decimal MaxWeight { get; set; }
+
+ ///
+ /// Bike weight in kg
+ ///
+ public decimal Weight { get; set; }
+
+ ///
+ /// Brake type
+ ///
+ public string BrakeType { get; set; } = string.Empty;
+
+ ///
+ /// Model year
+ ///
+ public int ModelYear { get; set; }
+
+ ///
+ /// Price per rental hour
+ ///
+ public decimal PricePerHour { get; set; }
+}
\ No newline at end of file
diff --git a/Bikes.Application.Contracts/Models/IBikeModelService.cs b/Bikes.Application.Contracts/Models/IBikeModelService.cs
new file mode 100644
index 000000000..e47678268
--- /dev/null
+++ b/Bikes.Application.Contracts/Models/IBikeModelService.cs
@@ -0,0 +1,8 @@
+namespace Bikes.Application.Contracts.Models;
+
+///
+/// Service for bike model management
+///
+public interface IBikeModelService : IApplicationService
+{
+}
\ No newline at end of file
diff --git a/Bikes.Application.Contracts/Renters/IRenterService.cs b/Bikes.Application.Contracts/Renters/IRenterService.cs
new file mode 100644
index 000000000..ecd43212a
--- /dev/null
+++ b/Bikes.Application.Contracts/Renters/IRenterService.cs
@@ -0,0 +1,8 @@
+namespace Bikes.Application.Contracts.Renters;
+
+///
+/// Service for renter management
+///
+public interface IRenterService : IApplicationService
+{
+}
\ No newline at end of file
diff --git a/Bikes.Application.Contracts/Renters/RenterCreateUpdateDto.cs b/Bikes.Application.Contracts/Renters/RenterCreateUpdateDto.cs
new file mode 100644
index 000000000..8e9c26154
--- /dev/null
+++ b/Bikes.Application.Contracts/Renters/RenterCreateUpdateDto.cs
@@ -0,0 +1,23 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Bikes.Application.Contracts.Renters;
+
+///
+/// DTO for creating and updating renters
+///
+public class RenterCreateUpdateDto
+{
+ ///
+ /// Renter full name
+ ///
+ [Required(ErrorMessage = "Full name is required")]
+ [StringLength(100, MinimumLength = 2, ErrorMessage = "Full name must be between 2 and 100 characters")]
+ public required string FullName { get; set; }
+
+ ///
+ /// Contact phone number
+ ///
+ [Required(ErrorMessage = "Phone number is required")]
+ [RegularExpression(@"^\+?[1-9]\d{1,14}$", ErrorMessage = "Phone number must be in valid international format")]
+ public required string Phone { get; set; }
+}
\ No newline at end of file
diff --git a/Bikes.Application.Contracts/Renters/RenterDto.cs b/Bikes.Application.Contracts/Renters/RenterDto.cs
new file mode 100644
index 000000000..8d97e66cd
--- /dev/null
+++ b/Bikes.Application.Contracts/Renters/RenterDto.cs
@@ -0,0 +1,22 @@
+namespace Bikes.Application.Contracts.Renters;
+
+///
+/// DTO for renter representation
+///
+public class RenterDto
+{
+ ///
+ /// Renter identifier
+ ///
+ public int Id { get; set; }
+
+ ///
+ /// Renter full name
+ ///
+ public string FullName { get; set; } = string.Empty;
+
+ ///
+ /// Contact phone number
+ ///
+ public string Phone { get; set; } = string.Empty;
+}
\ No newline at end of file
diff --git a/Bikes.Application.Contracts/Rents/IRentService.cs b/Bikes.Application.Contracts/Rents/IRentService.cs
new file mode 100644
index 000000000..a2e03394c
--- /dev/null
+++ b/Bikes.Application.Contracts/Rents/IRentService.cs
@@ -0,0 +1,12 @@
+namespace Bikes.Application.Contracts.Rents;
+
+///
+/// Service for rent management
+///
+public interface IRentService : IApplicationService
+{
+ ///
+ /// Receive and process list of rent contracts from Kafka
+ ///
+ public Task ReceiveContract(IList contracts);
+}
\ No newline at end of file
diff --git a/Bikes.Application.Contracts/Rents/RentCreateUpdateDto.cs b/Bikes.Application.Contracts/Rents/RentCreateUpdateDto.cs
new file mode 100644
index 000000000..4c5c3cf34
--- /dev/null
+++ b/Bikes.Application.Contracts/Rents/RentCreateUpdateDto.cs
@@ -0,0 +1,36 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Bikes.Application.Contracts.Rents;
+
+///
+/// DTO for creating and updating rents
+///
+public class RentCreateUpdateDto
+{
+ ///
+ /// Bike identifier
+ ///
+ [Required(ErrorMessage = "Bike ID is required")]
+ [Range(1, int.MaxValue, ErrorMessage = "Bike ID must be a positive number")]
+ public required int BikeId { get; set; }
+
+ ///
+ /// Renter identifier
+ ///
+ [Required(ErrorMessage = "Renter ID is required")]
+ [Range(1, int.MaxValue, ErrorMessage = "Renter ID must be a positive number")]
+ public required int RenterId { get; set; }
+
+ ///
+ /// Rental start time
+ ///
+ [Required(ErrorMessage = "Start time is required")]
+ public required DateTime StartTime { get; set; }
+
+ ///
+ /// Rental duration in hours
+ ///
+ [Required(ErrorMessage = "Duration is required")]
+ [Range(1, 24 * 7, ErrorMessage = "Duration must be between 1 and 168 hours (1 week)")]
+ public required int DurationHours { get; set; }
+}
\ No newline at end of file
diff --git a/Bikes.Application.Contracts/Rents/RentDto.cs b/Bikes.Application.Contracts/Rents/RentDto.cs
new file mode 100644
index 000000000..c54826ce3
--- /dev/null
+++ b/Bikes.Application.Contracts/Rents/RentDto.cs
@@ -0,0 +1,37 @@
+namespace Bikes.Application.Contracts.Rents;
+
+///
+/// DTO for rent representation
+///
+public class RentDto
+{
+ ///
+ /// Rent identifier
+ ///
+ public int Id { get; set; }
+
+ ///
+ /// Rented bike identifier
+ ///
+ public int BikeId { get; set; }
+
+ ///
+ /// Renter identifier
+ ///
+ public int RenterId { get; set; }
+
+ ///
+ /// Rental start time
+ ///
+ public DateTime StartTime { get; set; }
+
+ ///
+ /// Rental duration in hours
+ ///
+ public int DurationHours { get; set; }
+
+ ///
+ /// Total rental cost
+ ///
+ public decimal TotalCost { get; set; }
+}
\ No newline at end of file
diff --git a/Bikes.Application/Bikes.Application.csproj b/Bikes.Application/Bikes.Application.csproj
new file mode 100644
index 000000000..00730a53a
--- /dev/null
+++ b/Bikes.Application/Bikes.Application.csproj
@@ -0,0 +1,14 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Bikes.Application/Services/AnalyticsService.cs b/Bikes.Application/Services/AnalyticsService.cs
new file mode 100644
index 000000000..8be828e02
--- /dev/null
+++ b/Bikes.Application/Services/AnalyticsService.cs
@@ -0,0 +1,119 @@
+using Bikes.Application.Contracts.Analytics;
+using Bikes.Application.Contracts.Bikes;
+using Bikes.Domain.Models;
+using Bikes.Domain.Repositories;
+
+namespace Bikes.Application.Services;
+
+///
+/// Implementation of analytics service
+///
+public class AnalyticsService(
+ IBikeRepository bikeRepository,
+ IRentRepository rentRepository) : IAnalyticsService
+{
+ ///
+ /// Get all sport bikes
+ ///
+ public List GetSportBikes()
+ {
+ return [.. bikeRepository.GetAllBikes()
+ .Where(b => b.Model.Type == BikeType.Sport)
+ .Select(b => new BikeDto
+ {
+ Id = b.Id,
+ SerialNumber = b.SerialNumber,
+ ModelId = b.Model.Id,
+ Color = b.Color,
+ IsAvailable = b.IsAvailable
+ })];
+ }
+
+ ///
+ /// Get top 5 models by profit
+ ///
+ public List GetTop5ModelsByProfit()
+ {
+ var rents = rentRepository.GetAllRents();
+
+ return [.. rents
+ .GroupBy(r => r.Bike.Model)
+ .Select(g => new BikeModelAnalyticsDto
+ {
+ Id = g.Key.Id,
+ Name = g.Key.Name,
+ Type = g.Key.Type.ToString(),
+ PricePerHour = g.Key.PricePerHour,
+ TotalProfit = g.Sum(r => r.Bike.Model.PricePerHour * r.DurationHours),
+ TotalDuration = g.Sum(r => r.DurationHours)
+ })
+ .OrderByDescending(x => x.TotalProfit)
+ .Take(5)];
+ }
+
+ ///
+ /// Get top 5 models by rental duration
+ ///
+ public List GetTop5ModelsByRentalDuration()
+ {
+ var rents = rentRepository.GetAllRents();
+
+ return [.. rents
+ .GroupBy(r => r.Bike.Model)
+ .Select(g => new BikeModelAnalyticsDto
+ {
+ Id = g.Key.Id,
+ Name = g.Key.Name,
+ Type = g.Key.Type.ToString(),
+ PricePerHour = g.Key.PricePerHour,
+ TotalProfit = g.Sum(r => r.Bike.Model.PricePerHour * r.DurationHours),
+ TotalDuration = g.Sum(r => r.DurationHours)
+ })
+ .OrderByDescending(x => x.TotalDuration)
+ .Take(5)];
+ }
+
+ ///
+ /// Get rental statistics
+ ///
+ public RentalStatistics GetRentalStatistics()
+ {
+ var durations = rentRepository.GetAllRents()
+ .Select(r => r.DurationHours);
+
+ return new RentalStatistics(
+ MinDuration: durations.Min(),
+ MaxDuration: durations.Max(),
+ AvgDuration: durations.Average()
+ );
+ }
+
+ ///
+ /// Get total rental time by bike type
+ ///
+ public Dictionary GetTotalRentalTimeByBikeType()
+ {
+ return rentRepository.GetAllRents()
+ .GroupBy(r => r.Bike.Model.Type)
+ .ToDictionary(
+ g => g.Key.ToString(),
+ g => g.Sum(r => r.DurationHours)
+ );
+ }
+
+ ///
+ /// Get top renters by rental count
+ ///
+ public List GetTopRentersByRentalCount()
+ {
+ return [.. rentRepository.GetAllRents()
+ .GroupBy(r => r.Renter)
+ .Select(g => new RenterAnalyticsDto
+ {
+ FullName = g.Key.FullName,
+ RentalCount = g.Count()
+ })
+ .OrderByDescending(x => x.RentalCount)
+ .Take(5)];
+ }
+}
\ No newline at end of file
diff --git a/Bikes.Application/Services/BikeModelService.cs b/Bikes.Application/Services/BikeModelService.cs
new file mode 100644
index 000000000..0d6dd892a
--- /dev/null
+++ b/Bikes.Application/Services/BikeModelService.cs
@@ -0,0 +1,142 @@
+using Bikes.Application.Contracts.Models;
+using Bikes.Domain.Models;
+using Bikes.Domain.Repositories;
+
+namespace Bikes.Application.Services;
+
+///
+/// Implementation of bike model service
+///
+public class BikeModelService(IBikeModelRepository bikeModelRepository) : IBikeModelService
+{
+ ///
+ /// Get all bike models
+ ///
+ public List GetAll()
+ {
+ return [.. bikeModelRepository.GetAllModels().Select(m => new BikeModelDto
+ {
+ Id = m.Id,
+ Name = m.Name,
+ Type = m.Type.ToString(),
+ WheelSize = m.WheelSize,
+ MaxWeight = m.MaxWeight,
+ Weight = m.Weight,
+ BrakeType = m.BrakeType,
+ ModelYear = m.ModelYear,
+ PricePerHour = m.PricePerHour
+ })];
+ }
+
+ ///
+ /// Get bike model by identifier
+ ///
+ public BikeModelDto? GetById(int id)
+ {
+ var model = bikeModelRepository.GetModelById(id);
+ return model == null ? null : new BikeModelDto
+ {
+ Id = model.Id,
+ Name = model.Name,
+ Type = model.Type.ToString(),
+ WheelSize = model.WheelSize,
+ MaxWeight = model.MaxWeight,
+ Weight = model.Weight,
+ BrakeType = model.BrakeType,
+ ModelYear = model.ModelYear,
+ PricePerHour = model.PricePerHour
+ };
+ }
+
+ ///
+ /// Create new bike model
+ ///
+ public BikeModelDto Create(BikeModelCreateUpdateDto request)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+
+ if (!Enum.TryParse(request.Type, out var bikeType))
+ throw new InvalidOperationException($"Invalid bike type: {request.Type}");
+
+ var models = bikeModelRepository.GetAllModels();
+ var maxId = models.Count == 0 ? 0 : models.Max(m => m.Id);
+
+ var newModel = new BikeModel
+ {
+ Id = maxId + 1,
+ Name = request.Name,
+ Type = bikeType,
+ WheelSize = request.WheelSize,
+ MaxWeight = request.MaxWeight,
+ Weight = request.Weight,
+ BrakeType = request.BrakeType,
+ ModelYear = request.ModelYear,
+ PricePerHour = request.PricePerHour
+ };
+
+ bikeModelRepository.AddModel(newModel);
+
+ return new BikeModelDto
+ {
+ Id = newModel.Id,
+ Name = newModel.Name,
+ Type = newModel.Type.ToString(),
+ WheelSize = newModel.WheelSize,
+ MaxWeight = newModel.MaxWeight,
+ Weight = newModel.Weight,
+ BrakeType = newModel.BrakeType,
+ ModelYear = newModel.ModelYear,
+ PricePerHour = newModel.PricePerHour
+ };
+ }
+
+ ///
+ /// Update bike model
+ ///
+ public BikeModelDto? Update(int id, BikeModelCreateUpdateDto request)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+
+ if (!Enum.TryParse(request.Type, out var bikeType))
+ throw new InvalidOperationException($"Invalid bike type: {request.Type}");
+
+ var model = bikeModelRepository.GetModelById(id);
+ if (model == null) return null;
+
+ model.Name = request.Name;
+ model.Type = bikeType;
+ model.WheelSize = request.WheelSize;
+ model.MaxWeight = request.MaxWeight;
+ model.Weight = request.Weight;
+ model.BrakeType = request.BrakeType;
+ model.ModelYear = request.ModelYear;
+ model.PricePerHour = request.PricePerHour;
+
+ bikeModelRepository.UpdateModel(model);
+
+ return new BikeModelDto
+ {
+ Id = model.Id,
+ Name = model.Name,
+ Type = model.Type.ToString(),
+ WheelSize = model.WheelSize,
+ MaxWeight = model.MaxWeight,
+ Weight = model.Weight,
+ BrakeType = model.BrakeType,
+ ModelYear = model.ModelYear,
+ PricePerHour = model.PricePerHour
+ };
+ }
+
+ ///
+ /// Delete bike model
+ ///
+ public bool Delete(int id)
+ {
+ var model = bikeModelRepository.GetModelById(id);
+ if (model == null) return false;
+
+ bikeModelRepository.DeleteModel(id);
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/Bikes.Application/Services/BikeService.cs b/Bikes.Application/Services/BikeService.cs
new file mode 100644
index 000000000..c2b304060
--- /dev/null
+++ b/Bikes.Application/Services/BikeService.cs
@@ -0,0 +1,117 @@
+using Bikes.Application.Contracts.Bikes;
+using Bikes.Domain.Models;
+using Bikes.Domain.Repositories;
+
+namespace Bikes.Application.Services;
+
+///
+/// Implementation of bike service
+///
+public class BikeService(
+ IBikeRepository bikeRepository,
+ IBikeModelRepository bikeModelRepository) : IBikeService
+{
+ ///
+ /// Get all bikes
+ ///
+ public List GetAll()
+ {
+ return [.. bikeRepository.GetAllBikes().Select(b => new BikeDto
+ {
+ Id = b.Id,
+ SerialNumber = b.SerialNumber,
+ ModelId = b.Model.Id,
+ Color = b.Color,
+ IsAvailable = b.IsAvailable
+ })];
+ }
+
+ ///
+ /// Get bike by identifier
+ ///
+ public BikeDto? GetById(int id)
+ {
+ var bike = bikeRepository.GetBikeById(id);
+ return bike == null ? null : new BikeDto
+ {
+ Id = bike.Id,
+ SerialNumber = bike.SerialNumber,
+ ModelId = bike.Model.Id,
+ Color = bike.Color,
+ IsAvailable = bike.IsAvailable
+ };
+ }
+
+ ///
+ /// Create new bike
+ ///
+ public BikeDto Create(BikeCreateUpdateDto request)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+
+ var modelExists = bikeModelRepository.GetModelById(request.ModelId) != null;
+ if (!modelExists)
+ throw new InvalidOperationException("Model not found");
+
+ var newBike = new Bike
+ {
+ SerialNumber = request.SerialNumber,
+ ModelId = request.ModelId,
+ Color = request.Color,
+ IsAvailable = true
+ };
+
+ bikeRepository.AddBike(newBike);
+
+ return new BikeDto
+ {
+ Id = newBike.Id,
+ SerialNumber = newBike.SerialNumber,
+ ModelId = newBike.ModelId,
+ Color = newBike.Color,
+ IsAvailable = newBike.IsAvailable
+ };
+ }
+
+ ///
+ /// Update bike
+ ///
+ public BikeDto? Update(int id, BikeCreateUpdateDto request)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+
+ var bike = bikeRepository.GetBikeById(id);
+ if (bike == null) return null;
+
+ var modelExists = bikeModelRepository.GetModelById(request.ModelId) != null;
+ if (!modelExists)
+ throw new InvalidOperationException("Model not found");
+
+ bike.SerialNumber = request.SerialNumber;
+ bike.ModelId = request.ModelId;
+ bike.Color = request.Color;
+
+ bikeRepository.UpdateBike(bike);
+
+ return new BikeDto
+ {
+ Id = bike.Id,
+ SerialNumber = bike.SerialNumber,
+ ModelId = bike.ModelId,
+ Color = bike.Color,
+ IsAvailable = bike.IsAvailable
+ };
+ }
+
+ ///
+ /// Delete bike
+ ///
+ public bool Delete(int id)
+ {
+ var bike = bikeRepository.GetBikeById(id);
+ if (bike == null) return false;
+
+ bikeRepository.DeleteBike(id);
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/Bikes.Application/Services/RentService.cs b/Bikes.Application/Services/RentService.cs
new file mode 100644
index 000000000..91eebbd2a
--- /dev/null
+++ b/Bikes.Application/Services/RentService.cs
@@ -0,0 +1,155 @@
+using Bikes.Application.Contracts.Rents;
+using Bikes.Domain.Models;
+using Bikes.Domain.Repositories;
+
+namespace Bikes.Application.Services;
+
+///
+/// Implementation of rent service
+///
+public class RentService(
+ IRentRepository rentRepository,
+ IBikeRepository bikeRepository,
+ IRenterRepository renterRepository) : IRentService
+{
+ ///
+ /// Receive and process list of rent contracts from Kafka
+ ///
+ public async Task ReceiveContract(IList contracts)
+ {
+ if (contracts == null || contracts.Count == 0)
+ return;
+
+ foreach (var contract in contracts)
+ {
+ try
+ {
+ Create(contract);
+ }
+ catch (InvalidOperationException)
+ {
+ continue;
+ }
+ }
+
+ await Task.CompletedTask;
+ }
+
+ ///
+ /// Get all rents
+ ///
+ public List GetAll()
+ {
+ return [.. rentRepository.GetAllRents().Select(r => new RentDto
+ {
+ Id = r.Id,
+ BikeId = r.Bike.Id,
+ RenterId = r.Renter.Id,
+ StartTime = r.StartTime,
+ DurationHours = r.DurationHours,
+ TotalCost = r.Bike.Model.PricePerHour * r.DurationHours
+ })];
+ }
+
+ ///
+ /// Get rent by identifier
+ ///
+ public RentDto? GetById(int id)
+ {
+ var rent = rentRepository.GetRentById(id);
+ return rent == null ? null : new RentDto
+ {
+ Id = rent.Id,
+ BikeId = rent.Bike.Id,
+ RenterId = rent.Renter.Id,
+ StartTime = rent.StartTime,
+ DurationHours = rent.DurationHours,
+ TotalCost = rent.Bike.Model.PricePerHour * rent.DurationHours
+ };
+ }
+
+ ///
+ /// Create new rent
+ ///
+ public RentDto Create(RentCreateUpdateDto request)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+
+ var bike = bikeRepository.GetBikeById(request.BikeId);
+ var renter = renterRepository.GetRenterById(request.RenterId);
+ var rents = rentRepository.GetAllRents();
+
+ if (bike == null)
+ throw new InvalidOperationException("Bike not found");
+ if (renter == null)
+ throw new InvalidOperationException("Renter not found");
+
+ var maxId = rents.Count == 0 ? 0 : rents.Max(r => r.Id);
+
+ var newRent = new Rent
+ {
+ Id = maxId + 1,
+ Bike = bike,
+ Renter = renter,
+ StartTime = request.StartTime,
+ DurationHours = request.DurationHours
+ };
+
+ rentRepository.AddRent(newRent);
+
+ return new RentDto
+ {
+ Id = newRent.Id,
+ BikeId = newRent.Bike.Id,
+ RenterId = newRent.Renter.Id,
+ StartTime = newRent.StartTime,
+ DurationHours = newRent.DurationHours,
+ TotalCost = newRent.Bike.Model.PricePerHour * newRent.DurationHours
+ };
+ }
+
+ ///
+ /// Update rent
+ ///
+ public RentDto? Update(int id, RentCreateUpdateDto request)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+
+ var rent = rentRepository.GetRentById(id);
+ if (rent == null) return null;
+
+ var bike = bikeRepository.GetBikeById(request.BikeId)
+ ?? throw new InvalidOperationException("Bike not found");
+ var renter = renterRepository.GetRenterById(request.RenterId)
+ ?? throw new InvalidOperationException("Renter not found");
+
+ rent.Bike = bike;
+ rent.Renter = renter;
+ rent.StartTime = request.StartTime;
+ rent.DurationHours = request.DurationHours;
+
+ rentRepository.UpdateRent(rent);
+
+ return new RentDto
+ {
+ Id = rent.Id,
+ BikeId = rent.Bike.Id,
+ RenterId = rent.Renter.Id,
+ StartTime = rent.StartTime,
+ DurationHours = rent.DurationHours,
+ TotalCost = rent.Bike.Model.PricePerHour * rent.DurationHours
+ };
+ }
+
+ ///
+ /// Delete rent
+ ///
+ public bool Delete(int id)
+ {
+ var rent = rentRepository.GetRentById(id);
+ if (rent == null) return false;
+
+ rentRepository.DeleteRent(id);
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/Bikes.Application/Services/RenterService.cs b/Bikes.Application/Services/RenterService.cs
new file mode 100644
index 000000000..bcc0fe14c
--- /dev/null
+++ b/Bikes.Application/Services/RenterService.cs
@@ -0,0 +1,100 @@
+using Bikes.Application.Contracts.Renters;
+using Bikes.Domain.Models;
+using Bikes.Domain.Repositories;
+
+namespace Bikes.Application.Services;
+
+///
+/// Implementation of renter service
+///
+public class RenterService(IRenterRepository renterRepository) : IRenterService
+{
+ ///
+ /// Get all renters
+ ///
+ public List GetAll()
+ {
+ return [.. renterRepository.GetAllRenters().Select(r => new RenterDto
+ {
+ Id = r.Id,
+ FullName = r.FullName,
+ Phone = r.Phone
+ })];
+ }
+
+ ///
+ /// Get renter by identifier
+ ///
+ public RenterDto? GetById(int id)
+ {
+ var renter = renterRepository.GetRenterById(id);
+ return renter == null ? null : new RenterDto
+ {
+ Id = renter.Id,
+ FullName = renter.FullName,
+ Phone = renter.Phone
+ };
+ }
+
+ ///
+ /// Create new renter
+ ///
+ public RenterDto Create(RenterCreateUpdateDto request)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+
+ var renters = renterRepository.GetAllRenters();
+ var maxId = renters.Count == 0 ? 0 : renters.Max(r => r.Id);
+
+ var newRenter = new Renter
+ {
+ Id = maxId + 1,
+ FullName = request.FullName,
+ Phone = request.Phone
+ };
+
+ renterRepository.AddRenter(newRenter);
+
+ return new RenterDto
+ {
+ Id = newRenter.Id,
+ FullName = newRenter.FullName,
+ Phone = newRenter.Phone
+ };
+ }
+
+ ///
+ /// Update renter
+ ///
+ public RenterDto? Update(int id, RenterCreateUpdateDto request)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+
+ var renter = renterRepository.GetRenterById(id);
+ if (renter == null) return null;
+
+ renter.FullName = request.FullName;
+ renter.Phone = request.Phone;
+
+ renterRepository.UpdateRenter(renter);
+
+ return new RenterDto
+ {
+ Id = renter.Id,
+ FullName = renter.FullName,
+ Phone = renter.Phone
+ };
+ }
+
+ ///
+ /// Delete renter
+ ///
+ public bool Delete(int id)
+ {
+ var renter = renterRepository.GetRenterById(id);
+ if (renter == null) return false;
+
+ renterRepository.DeleteRenter(id);
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/Bikes.Domain/Bikes.Domain.csproj b/Bikes.Domain/Bikes.Domain.csproj
new file mode 100644
index 000000000..fa71b7ae6
--- /dev/null
+++ b/Bikes.Domain/Bikes.Domain.csproj
@@ -0,0 +1,9 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
diff --git a/Bikes.Domain/Models/Bike.cs b/Bikes.Domain/Models/Bike.cs
new file mode 100644
index 000000000..75be88566
--- /dev/null
+++ b/Bikes.Domain/Models/Bike.cs
@@ -0,0 +1,57 @@
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace Bikes.Domain.Models;
+
+///
+/// Bicycle entity
+///
+[Table("bikes")]
+public class Bike
+{
+ ///
+ /// Unique identifier
+ ///
+ [Key]
+ [Column("id")]
+ [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
+ public int Id { get; set; }
+
+ ///
+ /// Serial number of the bicycle
+ ///
+ [Required]
+ [Column("serial_number")]
+ public string SerialNumber { get; set; } = null!;
+
+ ///
+ /// Foreign key for bike model
+ ///
+ [Required]
+ [Column("model_id")]
+ public int ModelId { get; set; }
+
+ ///
+ /// Reference to bike model
+ ///
+ public virtual BikeModel Model { get; set; } = null!;
+
+ ///
+ /// Color of the bicycle
+ ///
+ [Required]
+ [Column("color")]
+ public string Color { get; set; } = null!;
+
+ ///
+ /// Availability status for rental
+ ///
+ [Required]
+ [Column("is_available")]
+ public bool IsAvailable { get; set; } = true;
+
+ ///
+ /// Navigation property for rents
+ ///
+ public virtual List Rents { get; set; } = [];
+}
\ No newline at end of file
diff --git a/Bikes.Domain/Models/BikeModel.cs b/Bikes.Domain/Models/BikeModel.cs
new file mode 100644
index 000000000..dd573ef64
--- /dev/null
+++ b/Bikes.Domain/Models/BikeModel.cs
@@ -0,0 +1,79 @@
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace Bikes.Domain.Models;
+
+///
+/// Bicycle model information
+///
+[Table("bike_models")]
+public class BikeModel
+{
+ ///
+ /// Unique identifier
+ ///
+ [Key]
+ [Column("id")]
+ public int Id { get; set; }
+
+ ///
+ /// Model name
+ ///
+ [Required]
+ [Column("name")]
+ public string Name { get; set; } = null!;
+
+ ///
+ /// Type of bicycle
+ ///
+ [Required]
+ [Column("type")]
+ public BikeType Type { get; set; }
+
+ ///
+ /// Wheel size in inches
+ ///
+ [Required]
+ [Column("wheel_size")]
+ public decimal WheelSize { get; set; }
+
+ ///
+ /// Maximum permissible passenger weight in kg
+ ///
+ [Required]
+ [Column("max_weight")]
+ public decimal MaxWeight { get; set; }
+
+ ///
+ /// Bicycle weight in kg
+ ///
+ [Required]
+ [Column("weight")]
+ public decimal Weight { get; set; }
+
+ ///
+ /// Type of brakes
+ ///
+ [Required]
+ [Column("brake_type")]
+ public string BrakeType { get; set; } = null!;
+
+ ///
+ /// Model year
+ ///
+ [Required]
+ [Column("model_year")]
+ public int ModelYear { get; set; }
+
+ ///
+ /// Price per hour of rental
+ ///
+ [Required]
+ [Column("price_per_hour")]
+ public decimal PricePerHour { get; set; }
+
+ ///
+ /// Navigation property for bikes
+ ///
+ public virtual List Bikes { get; set; } = [];
+}
\ No newline at end of file
diff --git a/Bikes.Domain/Models/BikeType.cs b/Bikes.Domain/Models/BikeType.cs
new file mode 100644
index 000000000..d7e75aa9f
--- /dev/null
+++ b/Bikes.Domain/Models/BikeType.cs
@@ -0,0 +1,32 @@
+namespace Bikes.Domain.Models;
+
+///
+/// Type of bicycle
+///
+public enum BikeType
+{
+ ///
+ /// Road bike
+ ///
+ Road,
+
+ ///
+ /// Mountain bike
+ ///
+ Mountain,
+
+ ///
+ /// Sport bike
+ ///
+ Sport,
+
+ ///
+ /// Hybrid bike
+ ///
+ Hybrid,
+
+ ///
+ /// Electric bike
+ ///
+ Electric
+}
\ No newline at end of file
diff --git a/Bikes.Domain/Models/Rent.cs b/Bikes.Domain/Models/Rent.cs
new file mode 100644
index 000000000..6345ca998
--- /dev/null
+++ b/Bikes.Domain/Models/Rent.cs
@@ -0,0 +1,62 @@
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace Bikes.Domain.Models;
+
+///
+/// Bicycle rental record
+///
+[Table("rents")]
+public class Rent
+{
+ ///
+ /// Unique identifier
+ ///
+ [Key]
+ [Column("id")]
+ public int Id { get; set; }
+
+ ///
+ /// Foreign key for bike
+ ///
+ [Required]
+ [Column("bike_id")]
+ public int BikeId { get; set; }
+
+ ///
+ /// Reference to rented bicycle
+ ///
+ public virtual Bike Bike { get; set; } = null!;
+
+ ///
+ /// Foreign key for renter
+ ///
+ [Required]
+ [Column("renter_id")]
+ public int RenterId { get; set; }
+
+ ///
+ /// Reference to renter
+ ///
+ public virtual Renter Renter { get; set; } = null!;
+
+ ///
+ /// Rental start time
+ ///
+ [Required]
+ [Column("start_time")]
+ public DateTime StartTime { get; set; }
+
+ ///
+ /// Rental duration in hours
+ ///
+ [Required]
+ [Column("duration_hours")]
+ public int DurationHours { get; set; }
+
+ ///
+ /// Total rental cost
+ ///
+ [Column("total_cost")]
+ public decimal TotalCost => Bike.Model.PricePerHour * DurationHours;
+}
\ No newline at end of file
diff --git a/Bikes.Domain/Models/Renter.cs b/Bikes.Domain/Models/Renter.cs
new file mode 100644
index 000000000..608025bc8
--- /dev/null
+++ b/Bikes.Domain/Models/Renter.cs
@@ -0,0 +1,37 @@
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace Bikes.Domain.Models;
+
+///
+/// Bicycle renter information
+///
+[Table("renters")]
+public class Renter
+{
+ ///
+ /// Unique identifier
+ ///
+ [Key]
+ [Column("id")]
+ public int Id { get; set; }
+
+ ///
+ /// Full name of the renter
+ ///
+ [Required]
+ [Column("full_name")]
+ public string FullName { get; set; } = null!;
+
+ ///
+ /// Contact phone number
+ ///
+ [Required]
+ [Column("phone")]
+ public string Phone { get; set; } = null!;
+
+ ///
+ /// Navigation property for rents
+ ///
+ public virtual List Rents { get; set; } = [];
+}
\ No newline at end of file
diff --git a/Bikes.Domain/Repositories/IBikeModelRepository.cs b/Bikes.Domain/Repositories/IBikeModelRepository.cs
new file mode 100644
index 000000000..ca3281fd5
--- /dev/null
+++ b/Bikes.Domain/Repositories/IBikeModelRepository.cs
@@ -0,0 +1,15 @@
+using Bikes.Domain.Models;
+
+namespace Bikes.Domain.Repositories;
+
+///
+/// Repository for bike model data access
+///
+public interface IBikeModelRepository
+{
+ public List GetAllModels();
+ public BikeModel? GetModelById(int id);
+ public void AddModel(BikeModel model);
+ public void UpdateModel(BikeModel model);
+ public void DeleteModel(int id);
+}
\ No newline at end of file
diff --git a/Bikes.Domain/Repositories/IBikeRepository.cs b/Bikes.Domain/Repositories/IBikeRepository.cs
new file mode 100644
index 000000000..b63b496be
--- /dev/null
+++ b/Bikes.Domain/Repositories/IBikeRepository.cs
@@ -0,0 +1,15 @@
+using Bikes.Domain.Models;
+
+namespace Bikes.Domain.Repositories;
+
+///
+/// Repository for bike data access
+///
+public interface IBikeRepository
+{
+ public List GetAllBikes();
+ public Bike? GetBikeById(int id);
+ public void AddBike(Bike bike);
+ public void UpdateBike(Bike bike);
+ public void DeleteBike(int id);
+}
\ No newline at end of file
diff --git a/Bikes.Domain/Repositories/IRentRepository.cs b/Bikes.Domain/Repositories/IRentRepository.cs
new file mode 100644
index 000000000..ca1fc55d3
--- /dev/null
+++ b/Bikes.Domain/Repositories/IRentRepository.cs
@@ -0,0 +1,15 @@
+using Bikes.Domain.Models;
+
+namespace Bikes.Domain.Repositories;
+
+///
+/// Repository for rent data access
+///
+public interface IRentRepository
+{
+ public List GetAllRents();
+ public Rent? GetRentById(int id);
+ public void AddRent(Rent rent);
+ public void UpdateRent(Rent rent);
+ public void DeleteRent(int id);
+}
\ No newline at end of file
diff --git a/Bikes.Domain/Repositories/IRenterRepository.cs b/Bikes.Domain/Repositories/IRenterRepository.cs
new file mode 100644
index 000000000..2443e2560
--- /dev/null
+++ b/Bikes.Domain/Repositories/IRenterRepository.cs
@@ -0,0 +1,15 @@
+using Bikes.Domain.Models;
+
+namespace Bikes.Domain.Repositories;
+
+///
+/// Repository for renter data access
+///
+public interface IRenterRepository
+{
+ public List GetAllRenters();
+ public Renter? GetRenterById(int id);
+ public void AddRenter(Renter renter);
+ public void UpdateRenter(Renter renter);
+ public void DeleteRenter(int id);
+}
\ No newline at end of file
diff --git a/Bikes.Generator.Kafka/Bikes.Generator.Kafka.csproj b/Bikes.Generator.Kafka/Bikes.Generator.Kafka.csproj
new file mode 100644
index 000000000..453d31b77
--- /dev/null
+++ b/Bikes.Generator.Kafka/Bikes.Generator.Kafka.csproj
@@ -0,0 +1,22 @@
+
+
+
+ net8.0
+ enable
+ enable
+ dotnet-Bikes.Generator.Kafka-b1d58c3b-9059-4dd2-8b92-981ad7eb6764
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Bikes.Generator.Kafka/IDataGenerator.cs b/Bikes.Generator.Kafka/IDataGenerator.cs
new file mode 100644
index 000000000..6495026de
--- /dev/null
+++ b/Bikes.Generator.Kafka/IDataGenerator.cs
@@ -0,0 +1,12 @@
+namespace Bikes.Generator.Kafka;
+
+///
+/// Interface for generating random test data
+///
+public interface IDataGenerator
+{
+ public IEnumerable GenerateBikeModels(int count);
+ public IEnumerable GenerateBikes(int count, List modelIds);
+ public IEnumerable GenerateRenters(int count);
+ public IEnumerable GenerateRents(int count, List bikeIds, List renterIds);
+}
\ No newline at end of file
diff --git a/Bikes.Generator.Kafka/KafkaProducerService.cs b/Bikes.Generator.Kafka/KafkaProducerService.cs
new file mode 100644
index 000000000..80dfc88b9
--- /dev/null
+++ b/Bikes.Generator.Kafka/KafkaProducerService.cs
@@ -0,0 +1,87 @@
+using Bikes.Generator.Kafka.Services;
+
+namespace Bikes.Generator.Kafka;
+
+///
+/// Background service for generating and sending a specified number of contracts at specified intervals
+///
+public class KafkaProducerService : BackgroundService
+{
+ private readonly int _batchSize;
+ private readonly int _payloadLimit;
+ private readonly int _waitTime;
+ private readonly IProducerService _producer;
+ private readonly ILogger _logger;
+ private readonly IDataGenerator _dataGenerator;
+
+ public KafkaProducerService(
+ IConfiguration configuration,
+ IProducerService producer,
+ ILogger logger,
+ IDataGenerator dataGenerator)
+ {
+ _batchSize = configuration.GetValue("Generator:BatchSize", 10);
+ _payloadLimit = configuration.GetValue("Generator:PayloadLimit", 100);
+ _waitTime = configuration.GetValue("Generator:WaitTime", 10000);
+
+ if (_batchSize <= 0)
+ throw new ArgumentException($"Invalid argument value for BatchSize: {_batchSize}");
+ if (_payloadLimit <= 0)
+ throw new ArgumentException($"Invalid argument value for PayloadLimit: {_payloadLimit}");
+ if (_waitTime <= 0)
+ throw new ArgumentException($"Invalid argument value for WaitTime: {_waitTime}");
+
+ _producer = producer;
+ _logger = logger;
+ _dataGenerator = dataGenerator;
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ _logger.LogInformation("Waiting 15 seconds for Kafka to become fully ready...");
+ await Task.Delay(15000, stoppingToken);
+
+ _logger.LogInformation("Starting to send {total} messages with {time}s interval with {batch} messages in batch",
+ _payloadLimit, _waitTime / 1000, _batchSize);
+
+ var counter = 0;
+ while (counter < _payloadLimit && !stoppingToken.IsCancellationRequested)
+ {
+ try
+ {
+ await GenerateAndSendBatchAsync();
+ counter += _batchSize;
+ _logger.LogInformation("Sent {sent} messages, total: {total}/{limit}",
+ _batchSize, counter, _payloadLimit);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Send batch with error. Retry");
+ }
+
+ await Task.Delay(_waitTime, stoppingToken);
+ }
+
+ _logger.LogInformation("Finished sending {total} messages with {time}s interval with {batch} messages in batch",
+ _payloadLimit, _waitTime / 1000, _batchSize);
+ }
+
+ private async Task GenerateAndSendBatchAsync()
+ {
+ // Generate required data for rents
+ var models = _dataGenerator.GenerateBikeModels(_batchSize / 4).ToList();
+ var renters = _dataGenerator.GenerateRenters(_batchSize / 4).ToList();
+ var modelIds = Enumerable.Range(1, models.Count).ToList();
+ var renterIds = Enumerable.Range(1, renters.Count).ToList();
+ var bikes = _dataGenerator.GenerateBikes(_batchSize / 4, modelIds).ToList();
+ var bikeIds = Enumerable.Range(1, bikes.Count).ToList();
+
+ // Generate only rents
+ var rents = _dataGenerator.GenerateRents(_batchSize, bikeIds, renterIds).ToList();
+
+ // Send list of rents
+ await _producer.SendAsync("bike-rents", rents);
+
+ _logger.LogInformation("Generated {RentCount} rents", rents.Count);
+ }
+}
\ No newline at end of file
diff --git a/Bikes.Generator.Kafka/Program.cs b/Bikes.Generator.Kafka/Program.cs
new file mode 100644
index 000000000..9fcb54bd0
--- /dev/null
+++ b/Bikes.Generator.Kafka/Program.cs
@@ -0,0 +1,31 @@
+using Bikes.Generator.Kafka;
+using Bikes.Generator.Kafka.Services;
+using Bikes.ServiceDefaults;
+
+var builder = Host.CreateApplicationBuilder(args);
+
+Console.WriteLine("=== Debug Info ===");
+Console.WriteLine($"ConnectionStrings__bikes-kafka: {builder.Configuration.GetConnectionString("bikes-kafka")}");
+Console.WriteLine($"Kafka:BootstrapServers: {builder.Configuration["Kafka:BootstrapServers"]}");
+Console.WriteLine($"Kafka__BootstrapServers: {builder.Configuration["Kafka__BootstrapServers"]}");
+
+Console.WriteLine("=== All Kafka-related env vars ===");
+foreach (var env in Environment.GetEnvironmentVariables().Cast())
+{
+ var key = env.Key.ToString()!;
+ if (key.Contains("kafka", StringComparison.OrdinalIgnoreCase) ||
+ key.Contains("KAFKA", StringComparison.OrdinalIgnoreCase))
+ {
+ Console.WriteLine($"{key} = {env.Value}");
+ }
+}
+Console.WriteLine("==================");
+
+builder.AddServiceDefaults();
+
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddHostedService();
+
+var host = builder.Build();
+await host.RunAsync();
\ No newline at end of file
diff --git a/Bikes.Generator.Kafka/Properties/launchSettings.json b/Bikes.Generator.Kafka/Properties/launchSettings.json
new file mode 100644
index 000000000..9507c37f1
--- /dev/null
+++ b/Bikes.Generator.Kafka/Properties/launchSettings.json
@@ -0,0 +1,12 @@
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "Bikes.Generator.Kafka": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "environmentVariables": {
+ "DOTNET_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/Bikes.Generator.Kafka/RandomDataGenerator.cs b/Bikes.Generator.Kafka/RandomDataGenerator.cs
new file mode 100644
index 000000000..400e142ae
--- /dev/null
+++ b/Bikes.Generator.Kafka/RandomDataGenerator.cs
@@ -0,0 +1,62 @@
+using Bogus;
+using Bikes.Application.Contracts.Bikes;
+using Bikes.Application.Contracts.Models;
+using Bikes.Application.Contracts.Renters;
+using Bikes.Application.Contracts.Rents;
+
+namespace Bikes.Generator.Kafka;
+
+///
+/// Implementation of IDataGenerator using Bogus for generating random test data
+///
+public class RandomDataGenerator : IDataGenerator
+{
+ private readonly Faker _bikeModelFaker;
+ private readonly Faker _bikeFaker;
+ private readonly Faker _renterFaker;
+ private readonly Faker _rentFaker;
+
+ public RandomDataGenerator()
+ {
+ var bikeTypes = new[] { "Mountain", "Road", "Hybrid", "City", "Sport" };
+ var brakeTypes = new[] { "Mechanical", "Hydraulic", "Rim" };
+ var colors = new[] { "Red", "Blue", "Green", "Black", "White", "Yellow", "Silver" };
+
+ _bikeModelFaker = new Faker()
+ .RuleFor(m => m.Name, f => $"Model {f.Commerce.ProductName()}")
+ .RuleFor(m => m.Type, f => f.PickRandom(bikeTypes))
+ .RuleFor(m => m.WheelSize, f => f.Random.Decimal(10, 30))
+ .RuleFor(m => m.MaxWeight, f => f.Random.Decimal(50, 300))
+ .RuleFor(m => m.Weight, f => f.Random.Decimal(5, 50))
+ .RuleFor(m => m.BrakeType, f => f.PickRandom(brakeTypes))
+ .RuleFor(m => m.ModelYear, f => f.Random.Int(2000, 2024))
+ .RuleFor(m => m.PricePerHour, f => f.Random.Decimal(1, 50));
+
+ _bikeFaker = new Faker()
+ .RuleFor(b => b.SerialNumber, f => $"SN{f.Random.AlphaNumeric(8).ToUpper()}")
+ .RuleFor(b => b.ModelId, f => f.Random.Int(1, 11))
+ .RuleFor(b => b.Color, f => f.PickRandom(colors));
+
+ _renterFaker = new Faker()
+ .RuleFor(r => r.FullName, f => f.Name.FullName())
+ .RuleFor(r => r.Phone, f => f.Phone.PhoneNumber("+7##########"));
+
+ _rentFaker = new Faker()
+ .RuleFor(r => r.BikeId, f => f.Random.Int(1, 10))
+ .RuleFor(r => r.RenterId, f => f.Random.Int(1, 12))
+ .RuleFor(r => r.StartTime, f => f.Date.Recent(30).ToUniversalTime())
+ .RuleFor(r => r.DurationHours, f => f.Random.Int(1, 168));
+ }
+
+ public IEnumerable GenerateBikeModels(int count)
+ => _bikeModelFaker.Generate(count);
+
+ public IEnumerable GenerateBikes(int count, List modelIds)
+ => _bikeFaker.Generate(count);
+
+ public IEnumerable GenerateRenters(int count)
+ => _renterFaker.Generate(count);
+
+ public IEnumerable GenerateRents(int count, List bikeIds, List renterIds)
+ => _rentFaker.Generate(count);
+}
\ No newline at end of file
diff --git a/Bikes.Generator.Kafka/Serializers/KeySerializer.cs b/Bikes.Generator.Kafka/Serializers/KeySerializer.cs
new file mode 100644
index 000000000..cf9b30659
--- /dev/null
+++ b/Bikes.Generator.Kafka/Serializers/KeySerializer.cs
@@ -0,0 +1,15 @@
+using Confluent.Kafka;
+using System.Text;
+
+namespace Bikes.Generator.Kafka.Serializers;
+
+///
+/// Serializer for Guid keys in Kafka messages
+///
+public class KeySerializer : ISerializer
+{
+ public byte[] Serialize(Guid data, SerializationContext context)
+ {
+ return Encoding.UTF8.GetBytes(data.ToString());
+ }
+}
\ No newline at end of file
diff --git a/Bikes.Generator.Kafka/Serializers/ValueSerializer.cs b/Bikes.Generator.Kafka/Serializers/ValueSerializer.cs
new file mode 100644
index 000000000..b6fdc83b8
--- /dev/null
+++ b/Bikes.Generator.Kafka/Serializers/ValueSerializer.cs
@@ -0,0 +1,21 @@
+using Confluent.Kafka;
+using System.Text;
+using System.Text.Json;
+using Bikes.Application.Contracts.Rents;
+
+namespace Bikes.Generator.Kafka.Serializers;
+
+///
+/// Serializer for list of RentCreateUpdateDto values in Kafka messages
+///
+public class ValueSerializer : ISerializer>
+{
+ public byte[] Serialize(IList data, SerializationContext context)
+ {
+ if (data == null || data.Count == 0)
+ return [];
+
+ var json = JsonSerializer.Serialize(data);
+ return Encoding.UTF8.GetBytes(json);
+ }
+}
\ No newline at end of file
diff --git a/Bikes.Generator.Kafka/Services/IProducerService.cs b/Bikes.Generator.Kafka/Services/IProducerService.cs
new file mode 100644
index 000000000..185d2371b
--- /dev/null
+++ b/Bikes.Generator.Kafka/Services/IProducerService.cs
@@ -0,0 +1,8 @@
+using Bikes.Application.Contracts.Rents;
+
+namespace Bikes.Generator.Kafka.Services;
+
+public interface IProducerService
+{
+ public Task SendAsync(string topic, IList items);
+}
\ No newline at end of file
diff --git a/Bikes.Generator.Kafka/Services/KafkaGeneratorService.cs b/Bikes.Generator.Kafka/Services/KafkaGeneratorService.cs
new file mode 100644
index 000000000..8cd7196a2
--- /dev/null
+++ b/Bikes.Generator.Kafka/Services/KafkaGeneratorService.cs
@@ -0,0 +1,107 @@
+using Bikes.Generator.Kafka.Serializers;
+using Confluent.Kafka;
+using Polly;
+using Bikes.Application.Contracts.Rents;
+
+namespace Bikes.Generator.Kafka.Services;
+
+///
+/// Service for producing messages to Kafka with retry logic and error handling
+///
+public class KafkaGeneratorService : IProducerService, IDisposable
+{
+ private readonly ILogger _logger;
+ private readonly IProducer> _producer;
+ private readonly int _retryCount;
+ private readonly int _retryDelayMs;
+ private bool _disposed = false;
+
+ public KafkaGeneratorService(
+ ILogger logger,
+ IConfiguration configuration)
+ {
+ _logger = logger;
+
+ var bootstrapServers = configuration.GetConnectionString("bikes-kafka")
+ ?? configuration["Kafka:BootstrapServers"]
+ ?? configuration["Kafka__BootstrapServers"]
+ ?? "localhost:9092";
+
+ _logger.LogInformation("Kafka BootstrapServers: {BootstrapServers}", bootstrapServers);
+
+ var config = new ProducerConfig
+ {
+ BootstrapServers = bootstrapServers,
+ MessageSendMaxRetries = 20,
+ RetryBackoffMs = 10000,
+ SocketTimeoutMs = 60000,
+ MessageTimeoutMs = 120000,
+ EnableDeliveryReports = true,
+ Acks = Acks.All,
+ SecurityProtocol = SecurityProtocol.Plaintext
+ };
+
+ _producer = new ProducerBuilder>(config)
+ .SetKeySerializer(new KeySerializer())
+ .SetValueSerializer(new ValueSerializer())
+ .SetLogHandler((_, message) =>
+ {
+ _logger.LogDebug("Kafka log: {Facility} {Message}", message.Facility, message.Message);
+ })
+ .SetErrorHandler((_, error) =>
+ {
+ if (error.IsFatal)
+ _logger.LogError("Kafka fatal error: {Reason} (Code: {Code})", error.Reason, error.Code);
+ else
+ _logger.LogWarning("Kafka error: {Reason} (Code: {Code})", error.Reason, error.Code);
+ })
+ .Build();
+
+ _retryCount = configuration.GetValue("Kafka:RetryCount", 10);
+ _retryDelayMs = configuration.GetValue("Kafka:RetryDelayMs", 5000);
+ }
+
+ public async Task SendAsync(string topic, IList items)
+ {
+ if (items == null || items.Count == 0)
+ return;
+
+ var retryPolicy = Policy
+ .Handle()
+ .WaitAndRetryAsync(
+ _retryCount,
+ attempt => TimeSpan.FromMilliseconds(_retryDelayMs * Math.Pow(2, attempt - 1)),
+ onRetry: (exception, delay, attempt, context) =>
+ {
+ _logger.LogWarning(exception,
+ "Retry {Attempt}/{MaxAttempts} after {Delay}ms",
+ attempt, _retryCount, delay.TotalMilliseconds);
+ });
+
+ await retryPolicy.ExecuteAsync(async () =>
+ {
+ var key = Guid.NewGuid();
+
+ var message = new Message>
+ {
+ Key = key,
+ Value = items
+ };
+
+ var result = await _producer.ProduceAsync(topic, message);
+ _logger.LogInformation("Sent {Count} items to Kafka topic {Topic} (Partition: {Partition}, Offset: {Offset})",
+ items.Count, topic, result.Partition, result.Offset);
+ });
+ }
+
+ public void Dispose()
+ {
+ if (!_disposed)
+ {
+ _producer?.Flush(TimeSpan.FromSeconds(5));
+ _producer?.Dispose();
+ _disposed = true;
+ }
+ GC.SuppressFinalize(this);
+ }
+}
\ No newline at end of file
diff --git a/Bikes.Generator.Kafka/appsettings.Development.json b/Bikes.Generator.Kafka/appsettings.Development.json
new file mode 100644
index 000000000..b2dcdb674
--- /dev/null
+++ b/Bikes.Generator.Kafka/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.Hosting.Lifetime": "Information"
+ }
+ }
+}
diff --git a/Bikes.Generator.Kafka/appsettings.json b/Bikes.Generator.Kafka/appsettings.json
new file mode 100644
index 000000000..21582870d
--- /dev/null
+++ b/Bikes.Generator.Kafka/appsettings.json
@@ -0,0 +1,19 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.Hosting.Lifetime": "Information"
+ }
+ },
+ "Generator": {
+ "BatchSize": 10,
+ "PayloadLimit": 100,
+ "WaitTime": 10000
+ },
+ "Kafka": {
+ "BootstrapServers": "bikes-kafka:9092",
+ "Topic": "bike-rents",
+ "RetryCount": 10,
+ "RetryDelayMs": 5000
+ }
+}
\ No newline at end of file
diff --git a/Bikes.Infrastructure.EfCore/Bikes.Infrastructure.EfCore.csproj b/Bikes.Infrastructure.EfCore/Bikes.Infrastructure.EfCore.csproj
new file mode 100644
index 000000000..9833c6855
--- /dev/null
+++ b/Bikes.Infrastructure.EfCore/Bikes.Infrastructure.EfCore.csproj
@@ -0,0 +1,24 @@
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Bikes.Infrastructure.EfCore/BikesDbContext.cs b/Bikes.Infrastructure.EfCore/BikesDbContext.cs
new file mode 100644
index 000000000..2266ede0e
--- /dev/null
+++ b/Bikes.Infrastructure.EfCore/BikesDbContext.cs
@@ -0,0 +1,87 @@
+using Microsoft.EntityFrameworkCore;
+using Bikes.Domain.Models;
+
+namespace Bikes.Infrastructure.EfCore;
+
+///
+/// Database context for bikes rental system
+///
+public class BikesDbContext(DbContextOptions options) : DbContext(options)
+{
+ ///
+ /// Bike models table
+ ///
+ public DbSet BikeModels { get; set; } = null!;
+
+ ///
+ /// Bikes table
+ ///
+ public DbSet Bikes { get; set; } = null!;
+
+ ///
+ /// Renters table
+ ///
+ public DbSet Renters { get; set; } = null!;
+
+ ///
+ /// Rents table
+ ///
+ public DbSet Rents { get; set; } = null!;
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ base.OnModelCreating(modelBuilder);
+
+ // BikeModel configuration
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(e => e.Id);
+ entity.Property(e => e.Name).IsRequired().HasMaxLength(100);
+ entity.Property(e => e.Type).IsRequired().HasConversion();
+ entity.Property(e => e.WheelSize).HasPrecision(5, 2);
+ entity.Property(e => e.MaxWeight).HasPrecision(7, 2);
+ entity.Property(e => e.Weight).HasPrecision(7, 2);
+ entity.Property(e => e.BrakeType).IsRequired().HasMaxLength(50);
+ entity.Property(e => e.PricePerHour).HasPrecision(10, 2);
+ });
+
+ // Bike configuration
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(e => e.Id);
+ entity.Property(e => e.SerialNumber).IsRequired().HasMaxLength(50);
+ entity.Property(e => e.Color).IsRequired().HasMaxLength(30);
+
+ entity.HasOne(e => e.Model)
+ .WithMany(m => m.Bikes)
+ .HasForeignKey(e => e.ModelId)
+ .OnDelete(DeleteBehavior.Restrict);
+ });
+
+ // Renter configuration
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(e => e.Id);
+ entity.Property(e => e.FullName).IsRequired().HasMaxLength(100);
+ entity.Property(e => e.Phone).IsRequired().HasMaxLength(20);
+ });
+
+ // Rent configuration
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(e => e.Id);
+ entity.Property(e => e.StartTime).IsRequired();
+ entity.Property(e => e.DurationHours).IsRequired();
+
+ entity.HasOne(e => e.Bike)
+ .WithMany(b => b.Rents)
+ .HasForeignKey(e => e.BikeId)
+ .OnDelete(DeleteBehavior.Restrict);
+
+ entity.HasOne(e => e.Renter)
+ .WithMany(r => r.Rents)
+ .HasForeignKey(e => e.RenterId)
+ .OnDelete(DeleteBehavior.Restrict);
+ });
+ }
+}
\ No newline at end of file
diff --git a/Bikes.Infrastructure.EfCore/DataSeeder.cs b/Bikes.Infrastructure.EfCore/DataSeeder.cs
new file mode 100644
index 000000000..90ddf024f
--- /dev/null
+++ b/Bikes.Infrastructure.EfCore/DataSeeder.cs
@@ -0,0 +1,179 @@
+using Bikes.Domain.Models;
+using Microsoft.EntityFrameworkCore;
+
+namespace Bikes.Infrastructure.EfCore;
+
+///
+/// Seeds initial data to database
+///
+public static class DataSeeder
+{
+ ///
+ /// Seed initial data
+ ///
+ public static void Seed(this BikesDbContext context)
+ {
+ if (context.BikeModels.Any()) return;
+
+ var models = InitializeModels();
+ var bikes = InitializeBikes(models);
+ var renters = InitializeRenters();
+ var rents = InitializeRents(bikes, renters);
+
+ context.BikeModels.AddRange(models);
+ context.Bikes.AddRange(bikes);
+ context.Renters.AddRange(renters);
+ context.Rents.AddRange(rents);
+
+ context.SaveChanges();
+
+ FixSequences(context);
+ }
+
+ private static void FixSequences(BikesDbContext context)
+ {
+ var tables = new[] { "bike_models", "bikes", "renters", "rents" };
+
+ foreach (var table in tables)
+ {
+ var sql = $@"
+ SELECT setval(
+ pg_get_serial_sequence('{table}', 'id'),
+ COALESCE((SELECT MAX(id) FROM {table}), 1),
+ true
+ )";
+
+ context.Database.ExecuteSqlRaw(sql);
+ }
+ }
+
+ private static List InitializeModels()
+ {
+ return
+ [
+ new() { Id = 1, Name = "Sport Pro 1000", Type = BikeType.Sport, WheelSize = 28, MaxWeight = 120, Weight = 10, BrakeType = "Disc", ModelYear = 2023, PricePerHour = 15 },
+ new() { Id = 2, Name = "Mountain Extreme", Type = BikeType.Mountain, WheelSize = 29, MaxWeight = 130, Weight = 12, BrakeType = "Hydraulic", ModelYear = 2023, PricePerHour = 12 },
+ new() { Id = 3, Name = "Road Racer", Type = BikeType.Road, WheelSize = 26, MaxWeight = 110, Weight = 8, BrakeType = "Rim", ModelYear = 2023, PricePerHour = 10 },
+ new() { Id = 4, Name = "Sport Elite", Type = BikeType.Sport, WheelSize = 27.5m, MaxWeight = 125, Weight = 11, BrakeType = "Disc", ModelYear = 2023, PricePerHour = 14 },
+ new() { Id = 5, Name = "Hybrid Comfort", Type = BikeType.Hybrid, WheelSize = 28, MaxWeight = 135, Weight = 13, BrakeType = "Rim", ModelYear = 2023, PricePerHour = 8 },
+ new() { Id = 6, Name = "Electric City", Type = BikeType.Electric, WheelSize = 26, MaxWeight = 140, Weight = 20, BrakeType = "Disc", ModelYear = 2023, PricePerHour = 20 },
+ new() { Id = 7, Name = "Sport Lightning", Type = BikeType.Sport, WheelSize = 29, MaxWeight = 115, Weight = 9.5m, BrakeType = "Hydraulic", ModelYear = 2023, PricePerHour = 16 },
+ new() { Id = 8, Name = "Mountain King", Type = BikeType.Mountain, WheelSize = 27.5m, MaxWeight = 128, Weight = 11.5m, BrakeType = "Disc", ModelYear = 2023, PricePerHour = 13 },
+ new() { Id = 9, Name = "Road Speed", Type = BikeType.Road, WheelSize = 28, MaxWeight = 105, Weight = 7.5m, BrakeType = "Rim", ModelYear = 2023, PricePerHour = 11 },
+ new() { Id = 10, Name = "Sport Thunder", Type = BikeType.Sport, WheelSize = 26, MaxWeight = 122, Weight = 10.5m, BrakeType = "Disc", ModelYear = 2023, PricePerHour = 15.5m },
+ new() { Id = 11, Name = "Electric Mountain", Type = BikeType.Electric, WheelSize = 29, MaxWeight = 145, Weight = 22, BrakeType = "Hydraulic", ModelYear = 2023, PricePerHour = 25 }
+ ];
+ }
+
+ private static List InitializeBikes(List models)
+ {
+ var bikes = new List();
+ var colors = new[] { "Red", "Blue", "Green", "Black", "White", "Yellow" };
+
+ var bikeConfigurations = new[]
+ {
+ new { ModelIndex = 0, ColorIndex = 0 },
+ new { ModelIndex = 1, ColorIndex = 1 },
+ new { ModelIndex = 2, ColorIndex = 2 },
+ new { ModelIndex = 3, ColorIndex = 3 },
+ new { ModelIndex = 4, ColorIndex = 4 },
+ new { ModelIndex = 5, ColorIndex = 5 },
+ new { ModelIndex = 6, ColorIndex = 0 },
+ new { ModelIndex = 7, ColorIndex = 1 },
+ new { ModelIndex = 8, ColorIndex = 2 },
+ new { ModelIndex = 9, ColorIndex = 3 },
+ new { ModelIndex = 10, ColorIndex = 4 },
+ new { ModelIndex = 0, ColorIndex = 5 },
+ new { ModelIndex = 1, ColorIndex = 0 },
+ new { ModelIndex = 2, ColorIndex = 1 },
+ new { ModelIndex = 3, ColorIndex = 2 }
+ };
+
+ for (var i = 0; i < bikeConfigurations.Length; i++)
+ {
+ var config = bikeConfigurations[i];
+ var model = models[config.ModelIndex];
+ var color = colors[config.ColorIndex];
+
+ bikes.Add(new Bike
+ {
+ Id = i + 1,
+ SerialNumber = $"SN{(i + 1):D6}",
+ ModelId = model.Id,
+ Model = model,
+ Color = color,
+ IsAvailable = i % 2 == 0
+ });
+ }
+
+ return bikes;
+ }
+
+ private static List InitializeRenters()
+ {
+ return
+ [
+ new() { Id = 1, FullName = "Ivanov Ivan", Phone = "+79111111111" },
+ new() { Id = 2, FullName = "Petrov Petr", Phone = "+79112222222" },
+ new() { Id = 3, FullName = "Sidorov Alexey", Phone = "+79113333333" },
+ new() { Id = 4, FullName = "Kuznetsova Maria", Phone = "+79114444444" },
+ new() { Id = 5, FullName = "Smirnov Dmitry", Phone = "+79115555555" },
+ new() { Id = 6, FullName = "Vasilyeva Ekaterina", Phone = "+79116666666" },
+ new() { Id = 7, FullName = "Popov Artem", Phone = "+79117777777" },
+ new() { Id = 8, FullName = "Lebedeva Olga", Phone = "+79118888888" },
+ new() { Id = 9, FullName = "Novikov Sergey", Phone = "+79119999999" },
+ new() { Id = 10, FullName = "Morozova Anna", Phone = "+79110000000" },
+ new() { Id = 11, FullName = "Volkov Pavel", Phone = "+79121111111" },
+ new() { Id = 12, FullName = "Sokolova Irina", Phone = "+79122222222" }
+ ];
+ }
+
+ private static List InitializeRents(List bikes, List renters)
+ {
+ var rents = new List();
+ var rentId = 1;
+
+ var rentalData = new[]
+ {
+ new { BikeIndex = 0, RenterIndex = 0, Duration = 5, DaysAgo = 2 },
+ new { BikeIndex = 1, RenterIndex = 1, Duration = 3, DaysAgo = 5 },
+ new { BikeIndex = 2, RenterIndex = 2, Duration = 8, DaysAgo = 1 },
+ new { BikeIndex = 0, RenterIndex = 3, Duration = 2, DaysAgo = 7 },
+ new { BikeIndex = 3, RenterIndex = 4, Duration = 12, DaysAgo = 3 },
+ new { BikeIndex = 1, RenterIndex = 5, Duration = 6, DaysAgo = 4 },
+ new { BikeIndex = 4, RenterIndex = 6, Duration = 4, DaysAgo = 6 },
+ new { BikeIndex = 2, RenterIndex = 7, Duration = 10, DaysAgo = 2 },
+ new { BikeIndex = 5, RenterIndex = 8, Duration = 7, DaysAgo = 1 },
+ new { BikeIndex = 3, RenterIndex = 9, Duration = 3, DaysAgo = 8 },
+ new { BikeIndex = 6, RenterIndex = 10, Duration = 15, DaysAgo = 2 },
+ new { BikeIndex = 4, RenterIndex = 11, Duration = 9, DaysAgo = 5 },
+ new { BikeIndex = 7, RenterIndex = 0, Duration = 2, DaysAgo = 3 },
+ new { BikeIndex = 5, RenterIndex = 1, Duration = 6, DaysAgo = 4 },
+ new { BikeIndex = 8, RenterIndex = 2, Duration = 11, DaysAgo = 1 },
+ new { BikeIndex = 6, RenterIndex = 3, Duration = 4, DaysAgo = 7 },
+ new { BikeIndex = 9, RenterIndex = 4, Duration = 8, DaysAgo = 2 },
+ new { BikeIndex = 7, RenterIndex = 5, Duration = 5, DaysAgo = 5 },
+ new { BikeIndex = 10, RenterIndex = 6, Duration = 13, DaysAgo = 3 },
+ new { BikeIndex = 8, RenterIndex = 7, Duration = 7, DaysAgo = 4 }
+ };
+
+ foreach (var data in rentalData)
+ {
+ var bike = bikes[data.BikeIndex];
+ var renter = renters[data.RenterIndex];
+
+ rents.Add(new Rent
+ {
+ Id = rentId++,
+ BikeId = bike.Id,
+ Bike = bike,
+ RenterId = renter.Id,
+ Renter = renter,
+ StartTime = DateTime.UtcNow.AddDays(-data.DaysAgo),
+ DurationHours = data.Duration
+ });
+ }
+
+ return rents;
+ }
+}
\ No newline at end of file
diff --git a/Bikes.Infrastructure.EfCore/EfCoreBikeModelRepository.cs b/Bikes.Infrastructure.EfCore/EfCoreBikeModelRepository.cs
new file mode 100644
index 000000000..27c195b68
--- /dev/null
+++ b/Bikes.Infrastructure.EfCore/EfCoreBikeModelRepository.cs
@@ -0,0 +1,62 @@
+using Microsoft.EntityFrameworkCore;
+using Bikes.Domain.Models;
+using Bikes.Domain.Repositories;
+
+namespace Bikes.Infrastructure.EfCore;
+
+///
+/// EF Core implementation of bike model repository
+///
+public class EfCoreBikeModelRepository(BikesDbContext context) : IBikeModelRepository
+{
+ ///
+ /// Get all bike models
+ ///
+ public List GetAllModels()
+ {
+ return [.. context.BikeModels.AsNoTracking()];
+ }
+
+ ///
+ /// Get bike model by identifier
+ ///
+ public BikeModel? GetModelById(int id)
+ {
+ return context.BikeModels
+ .AsNoTracking()
+ .FirstOrDefault(m => m.Id == id);
+ }
+
+ ///
+ /// Add new bike model
+ ///
+ public void AddModel(BikeModel model)
+ {
+ ArgumentNullException.ThrowIfNull(model);
+ context.BikeModels.Add(model);
+ context.SaveChanges();
+ }
+
+ ///
+ /// Update bike model
+ ///
+ public void UpdateModel(BikeModel model)
+ {
+ ArgumentNullException.ThrowIfNull(model);
+ context.BikeModels.Update(model);
+ context.SaveChanges();
+ }
+
+ ///
+ /// Delete bike model
+ ///
+ public void DeleteModel(int id)
+ {
+ var model = context.BikeModels.Find(id);
+ if (model != null)
+ {
+ context.BikeModels.Remove(model);
+ context.SaveChanges();
+ }
+ }
+}
\ No newline at end of file
diff --git a/Bikes.Infrastructure.EfCore/EfCoreBikeRepository.cs b/Bikes.Infrastructure.EfCore/EfCoreBikeRepository.cs
new file mode 100644
index 000000000..2085cc40d
--- /dev/null
+++ b/Bikes.Infrastructure.EfCore/EfCoreBikeRepository.cs
@@ -0,0 +1,65 @@
+using Microsoft.EntityFrameworkCore;
+using Bikes.Domain.Models;
+using Bikes.Domain.Repositories;
+
+namespace Bikes.Infrastructure.EfCore;
+
+///
+/// EF Core implementation of bike repository
+///
+public class EfCoreBikeRepository(BikesDbContext context) : IBikeRepository
+{
+ ///
+ /// Get all bikes
+ ///
+ public List GetAllBikes()
+ {
+ return [.. context.Bikes
+ .Include(b => b.Model)
+ .AsNoTracking()];
+ }
+
+ ///
+ /// Get bike by identifier
+ ///
+ public Bike? GetBikeById(int id)
+ {
+ return context.Bikes
+ .Include(b => b.Model)
+ .AsNoTracking()
+ .FirstOrDefault(b => b.Id == id);
+ }
+
+ ///
+ /// Add new bike
+ ///
+ public void AddBike(Bike bike)
+ {
+ ArgumentNullException.ThrowIfNull(bike);
+ context.Bikes.Add(bike);
+ context.SaveChanges();
+ }
+
+ ///
+ /// Update bike
+ ///
+ public void UpdateBike(Bike bike)
+ {
+ ArgumentNullException.ThrowIfNull(bike);
+ context.Bikes.Update(bike);
+ context.SaveChanges();
+ }
+
+ ///
+ /// Delete bike
+ ///
+ public void DeleteBike(int id)
+ {
+ var bike = context.Bikes.Find(id);
+ if (bike != null)
+ {
+ context.Bikes.Remove(bike);
+ context.SaveChanges();
+ }
+ }
+}
\ No newline at end of file
diff --git a/Bikes.Infrastructure.EfCore/EfCoreRentRepository.cs b/Bikes.Infrastructure.EfCore/EfCoreRentRepository.cs
new file mode 100644
index 000000000..46d211c35
--- /dev/null
+++ b/Bikes.Infrastructure.EfCore/EfCoreRentRepository.cs
@@ -0,0 +1,85 @@
+using Microsoft.EntityFrameworkCore;
+using Bikes.Domain.Models;
+using Bikes.Domain.Repositories;
+
+namespace Bikes.Infrastructure.EfCore;
+
+///
+/// EF Core implementation of rent repository
+///
+public class EfCoreRentRepository(BikesDbContext context) : IRentRepository
+{
+ ///
+ /// Get all rents
+ ///
+ public List GetAllRents()
+ {
+ return [.. context.Rents
+ .Include(r => r.Bike)
+ .ThenInclude(b => b.Model)
+ .Include(r => r.Renter)
+ .AsNoTracking()];
+ }
+
+ ///
+ /// Get rent by identifier
+ ///
+ public Rent? GetRentById(int id)
+ {
+ return context.Rents
+ .Include(r => r.Bike)
+ .ThenInclude(b => b.Model)
+ .Include(r => r.Renter)
+ .AsNoTracking()
+ .FirstOrDefault(r => r.Id == id);
+ }
+
+ ///
+ /// Add new rent
+ ///
+ public void AddRent(Rent rent)
+ {
+ ArgumentNullException.ThrowIfNull(rent);
+
+ if (rent.Bike != null && context.Entry(rent.Bike).State == EntityState.Detached)
+ {
+ context.Attach(rent.Bike);
+
+ if (rent.Bike.Model != null && context.Entry(rent.Bike.Model).State == EntityState.Detached)
+ {
+ context.Attach(rent.Bike.Model);
+ }
+ }
+
+ if (rent.Renter != null && context.Entry(rent.Renter).State == EntityState.Detached)
+ {
+ context.Attach(rent.Renter);
+ }
+
+ context.Rents.Add(rent);
+ context.SaveChanges();
+ }
+
+ ///
+ /// Update rent
+ ///
+ public void UpdateRent(Rent rent)
+ {
+ ArgumentNullException.ThrowIfNull(rent);
+ context.Rents.Update(rent);
+ context.SaveChanges();
+ }
+
+ ///
+ /// Delete rent
+ ///
+ public void DeleteRent(int id)
+ {
+ var rent = context.Rents.Find(id);
+ if (rent != null)
+ {
+ context.Rents.Remove(rent);
+ context.SaveChanges();
+ }
+ }
+}
\ No newline at end of file
diff --git a/Bikes.Infrastructure.EfCore/EfCoreRenterRepository.cs b/Bikes.Infrastructure.EfCore/EfCoreRenterRepository.cs
new file mode 100644
index 000000000..cb1f6aec0
--- /dev/null
+++ b/Bikes.Infrastructure.EfCore/EfCoreRenterRepository.cs
@@ -0,0 +1,62 @@
+using Microsoft.EntityFrameworkCore;
+using Bikes.Domain.Models;
+using Bikes.Domain.Repositories;
+
+namespace Bikes.Infrastructure.EfCore;
+
+///
+/// EF Core implementation of renter repository
+///
+public class EfCoreRenterRepository(BikesDbContext context) : IRenterRepository
+{
+ ///
+ /// Get all renters
+ ///
+ public List GetAllRenters()
+ {
+ return [.. context.Renters.AsNoTracking()];
+ }
+
+ ///
+ /// Get renter by identifier
+ ///
+ public Renter? GetRenterById(int id)
+ {
+ return context.Renters
+ .AsNoTracking()
+ .FirstOrDefault(r => r.Id == id);
+ }
+
+ ///
+ /// Add new renter
+ ///
+ public void AddRenter(Renter renter)
+ {
+ ArgumentNullException.ThrowIfNull(renter);
+ context.Renters.Add(renter);
+ context.SaveChanges();
+ }
+
+ ///
+ /// Update renter
+ ///
+ public void UpdateRenter(Renter renter)
+ {
+ ArgumentNullException.ThrowIfNull(renter);
+ context.Renters.Update(renter);
+ context.SaveChanges();
+ }
+
+ ///
+ /// Delete renter
+ ///
+ public void DeleteRenter(int id)
+ {
+ var renter = context.Renters.Find(id);
+ if (renter != null)
+ {
+ context.Renters.Remove(renter);
+ context.SaveChanges();
+ }
+ }
+}
\ No newline at end of file
diff --git a/Bikes.Infrastructure.EfCore/Migrations/20251117201300_Initial.Designer.cs b/Bikes.Infrastructure.EfCore/Migrations/20251117201300_Initial.Designer.cs
new file mode 100644
index 000000000..99e06b90f
--- /dev/null
+++ b/Bikes.Infrastructure.EfCore/Migrations/20251117201300_Initial.Designer.cs
@@ -0,0 +1,226 @@
+//
+using System;
+using Bikes.Infrastructure.EfCore;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace Bikes.Infrastructure.EfCore.Migrations
+{
+ [DbContext(typeof(BikesDbContext))]
+ [Migration("20251117201300_Initial")]
+ partial class Initial
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.0")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Bikes.Domain.Models.Bike", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Color")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)")
+ .HasColumnName("color");
+
+ b.Property("IsAvailable")
+ .HasColumnType("boolean")
+ .HasColumnName("is_available");
+
+ b.Property("ModelId")
+ .HasColumnType("integer")
+ .HasColumnName("model_id");
+
+ b.Property("SerialNumber")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("serial_number");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ModelId");
+
+ b.ToTable("bikes");
+ });
+
+ modelBuilder.Entity("Bikes.Domain.Models.BikeModel", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("BrakeType")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("brake_type");
+
+ b.Property("MaxWeight")
+ .HasPrecision(7, 2)
+ .HasColumnType("numeric(7,2)")
+ .HasColumnName("max_weight");
+
+ b.Property("ModelYear")
+ .HasColumnType("integer")
+ .HasColumnName("model_year");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("name");
+
+ b.Property("PricePerHour")
+ .HasPrecision(10, 2)
+ .HasColumnType("numeric(10,2)")
+ .HasColumnName("price_per_hour");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("type");
+
+ b.Property("Weight")
+ .HasPrecision(7, 2)
+ .HasColumnType("numeric(7,2)")
+ .HasColumnName("weight");
+
+ b.Property("WheelSize")
+ .HasPrecision(5, 2)
+ .HasColumnType("numeric(5,2)")
+ .HasColumnName("wheel_size");
+
+ b.HasKey("Id");
+
+ b.ToTable("bike_models");
+ });
+
+ modelBuilder.Entity("Bikes.Domain.Models.Rent", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("BikeId")
+ .HasColumnType("integer")
+ .HasColumnName("bike_id");
+
+ b.Property("DurationHours")
+ .HasColumnType("integer")
+ .HasColumnName("duration_hours");
+
+ b.Property("RenterId")
+ .HasColumnType("integer")
+ .HasColumnName("renter_id");
+
+ b.Property("StartTime")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("start_time");
+
+ b.HasKey("Id");
+
+ b.HasIndex("BikeId");
+
+ b.HasIndex("RenterId");
+
+ b.ToTable("rents");
+ });
+
+ modelBuilder.Entity("Bikes.Domain.Models.Renter", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("FullName")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("full_name");
+
+ b.Property("Phone")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasColumnName("phone");
+
+ b.HasKey("Id");
+
+ b.ToTable("renters");
+ });
+
+ modelBuilder.Entity("Bikes.Domain.Models.Bike", b =>
+ {
+ b.HasOne("Bikes.Domain.Models.BikeModel", "Model")
+ .WithMany("Bikes")
+ .HasForeignKey("ModelId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Model");
+ });
+
+ modelBuilder.Entity("Bikes.Domain.Models.Rent", b =>
+ {
+ b.HasOne("Bikes.Domain.Models.Bike", "Bike")
+ .WithMany("Rents")
+ .HasForeignKey("BikeId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("Bikes.Domain.Models.Renter", "Renter")
+ .WithMany("Rents")
+ .HasForeignKey("RenterId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Bike");
+
+ b.Navigation("Renter");
+ });
+
+ modelBuilder.Entity("Bikes.Domain.Models.Bike", b =>
+ {
+ b.Navigation("Rents");
+ });
+
+ modelBuilder.Entity("Bikes.Domain.Models.BikeModel", b =>
+ {
+ b.Navigation("Bikes");
+ });
+
+ modelBuilder.Entity("Bikes.Domain.Models.Renter", b =>
+ {
+ b.Navigation("Rents");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/Bikes.Infrastructure.EfCore/Migrations/20251117201300_Initial.cs b/Bikes.Infrastructure.EfCore/Migrations/20251117201300_Initial.cs
new file mode 100644
index 000000000..fbb96b66c
--- /dev/null
+++ b/Bikes.Infrastructure.EfCore/Migrations/20251117201300_Initial.cs
@@ -0,0 +1,129 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace Bikes.Infrastructure.EfCore.Migrations;
+
+///
+public partial class Initial : Migration
+{
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "bike_models",
+ columns: table => new
+ {
+ id = table.Column(type: "integer", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false),
+ type = table.Column(type: "text", nullable: false),
+ wheel_size = table.Column(type: "numeric(5,2)", precision: 5, scale: 2, nullable: false),
+ max_weight = table.Column(type: "numeric(7,2)", precision: 7, scale: 2, nullable: false),
+ weight = table.Column(type: "numeric(7,2)", precision: 7, scale: 2, nullable: false),
+ brake_type = table.Column(type: "character varying(50)", maxLength: 50, nullable: false),
+ model_year = table.Column(type: "integer", nullable: false),
+ price_per_hour = table.Column(type: "numeric(10,2)", precision: 10, scale: 2, nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_bike_models", x => x.id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "renters",
+ columns: table => new
+ {
+ id = table.Column(type: "integer", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ full_name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false),
+ phone = table.Column(type: "character varying(20)", maxLength: 20, nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_renters", x => x.id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "bikes",
+ columns: table => new
+ {
+ id = table.Column(type: "integer", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ serial_number = table.Column(type: "character varying(50)", maxLength: 50, nullable: false),
+ model_id = table.Column(type: "integer", nullable: false),
+ color = table.Column(type: "character varying(30)", maxLength: 30, nullable: false),
+ is_available = table.Column(type: "boolean", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_bikes", x => x.id);
+ table.ForeignKey(
+ name: "FK_bikes_bike_models_model_id",
+ column: x => x.model_id,
+ principalTable: "bike_models",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Restrict);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "rents",
+ columns: table => new
+ {
+ id = table.Column(type: "integer", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ bike_id = table.Column(type: "integer", nullable: false),
+ renter_id = table.Column(type: "integer", nullable: false),
+ start_time = table.Column(type: "timestamp with time zone", nullable: false),
+ duration_hours = table.Column(type: "integer", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_rents", x => x.id);
+ table.ForeignKey(
+ name: "FK_rents_bikes_bike_id",
+ column: x => x.bike_id,
+ principalTable: "bikes",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Restrict);
+ table.ForeignKey(
+ name: "FK_rents_renters_renter_id",
+ column: x => x.renter_id,
+ principalTable: "renters",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Restrict);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_bikes_model_id",
+ table: "bikes",
+ column: "model_id");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_rents_bike_id",
+ table: "rents",
+ column: "bike_id");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_rents_renter_id",
+ table: "rents",
+ column: "renter_id");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "rents");
+
+ migrationBuilder.DropTable(
+ name: "bikes");
+
+ migrationBuilder.DropTable(
+ name: "renters");
+
+ migrationBuilder.DropTable(
+ name: "bike_models");
+ }
+}
\ No newline at end of file
diff --git a/Bikes.Infrastructure.EfCore/Migrations/BikesDbContextModelSnapshot.cs b/Bikes.Infrastructure.EfCore/Migrations/BikesDbContextModelSnapshot.cs
new file mode 100644
index 000000000..071076da6
--- /dev/null
+++ b/Bikes.Infrastructure.EfCore/Migrations/BikesDbContextModelSnapshot.cs
@@ -0,0 +1,223 @@
+//
+using System;
+using Bikes.Infrastructure.EfCore;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace Bikes.Infrastructure.EfCore.Migrations
+{
+ [DbContext(typeof(BikesDbContext))]
+ partial class BikesDbContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.0")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Bikes.Domain.Models.Bike", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Color")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)")
+ .HasColumnName("color");
+
+ b.Property("IsAvailable")
+ .HasColumnType("boolean")
+ .HasColumnName("is_available");
+
+ b.Property("ModelId")
+ .HasColumnType("integer")
+ .HasColumnName("model_id");
+
+ b.Property("SerialNumber")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("serial_number");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ModelId");
+
+ b.ToTable("bikes");
+ });
+
+ modelBuilder.Entity("Bikes.Domain.Models.BikeModel", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("BrakeType")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("brake_type");
+
+ b.Property("MaxWeight")
+ .HasPrecision(7, 2)
+ .HasColumnType("numeric(7,2)")
+ .HasColumnName("max_weight");
+
+ b.Property("ModelYear")
+ .HasColumnType("integer")
+ .HasColumnName("model_year");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("name");
+
+ b.Property("PricePerHour")
+ .HasPrecision(10, 2)
+ .HasColumnType("numeric(10,2)")
+ .HasColumnName("price_per_hour");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("type");
+
+ b.Property("Weight")
+ .HasPrecision(7, 2)
+ .HasColumnType("numeric(7,2)")
+ .HasColumnName("weight");
+
+ b.Property("WheelSize")
+ .HasPrecision(5, 2)
+ .HasColumnType("numeric(5,2)")
+ .HasColumnName("wheel_size");
+
+ b.HasKey("Id");
+
+ b.ToTable("bike_models");
+ });
+
+ modelBuilder.Entity("Bikes.Domain.Models.Rent", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("BikeId")
+ .HasColumnType("integer")
+ .HasColumnName("bike_id");
+
+ b.Property("DurationHours")
+ .HasColumnType("integer")
+ .HasColumnName("duration_hours");
+
+ b.Property("RenterId")
+ .HasColumnType("integer")
+ .HasColumnName("renter_id");
+
+ b.Property("StartTime")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("start_time");
+
+ b.HasKey("Id");
+
+ b.HasIndex("BikeId");
+
+ b.HasIndex("RenterId");
+
+ b.ToTable("rents");
+ });
+
+ modelBuilder.Entity("Bikes.Domain.Models.Renter", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("FullName")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("full_name");
+
+ b.Property("Phone")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasColumnName("phone");
+
+ b.HasKey("Id");
+
+ b.ToTable("renters");
+ });
+
+ modelBuilder.Entity("Bikes.Domain.Models.Bike", b =>
+ {
+ b.HasOne("Bikes.Domain.Models.BikeModel", "Model")
+ .WithMany("Bikes")
+ .HasForeignKey("ModelId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Model");
+ });
+
+ modelBuilder.Entity("Bikes.Domain.Models.Rent", b =>
+ {
+ b.HasOne("Bikes.Domain.Models.Bike", "Bike")
+ .WithMany("Rents")
+ .HasForeignKey("BikeId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("Bikes.Domain.Models.Renter", "Renter")
+ .WithMany("Rents")
+ .HasForeignKey("RenterId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Bike");
+
+ b.Navigation("Renter");
+ });
+
+ modelBuilder.Entity("Bikes.Domain.Models.Bike", b =>
+ {
+ b.Navigation("Rents");
+ });
+
+ modelBuilder.Entity("Bikes.Domain.Models.BikeModel", b =>
+ {
+ b.Navigation("Bikes");
+ });
+
+ modelBuilder.Entity("Bikes.Domain.Models.Renter", b =>
+ {
+ b.Navigation("Rents");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/Bikes.Infrastructure.EfCore/Properties/launchSettings.json b/Bikes.Infrastructure.EfCore/Properties/launchSettings.json
new file mode 100644
index 000000000..1cac07450
--- /dev/null
+++ b/Bikes.Infrastructure.EfCore/Properties/launchSettings.json
@@ -0,0 +1,12 @@
+{
+ "profiles": {
+ "Bikes.Infrastructure.EfCore": {
+ "commandName": "Project",
+ "launchBrowser": true,
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ },
+ "applicationUrl": "https://localhost:50514;http://localhost:50515"
+ }
+ }
+}
\ No newline at end of file
diff --git a/Bikes.Infrastructure.InMemory/Bikes.Infrastructure.InMemory.csproj b/Bikes.Infrastructure.InMemory/Bikes.Infrastructure.InMemory.csproj
new file mode 100644
index 000000000..f02574c1f
--- /dev/null
+++ b/Bikes.Infrastructure.InMemory/Bikes.Infrastructure.InMemory.csproj
@@ -0,0 +1,14 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Bikes.Infrastructure.InMemory/Repositories/InMemoryBikeRepository.cs b/Bikes.Infrastructure.InMemory/Repositories/InMemoryBikeRepository.cs
new file mode 100644
index 000000000..5a191334c
--- /dev/null
+++ b/Bikes.Infrastructure.InMemory/Repositories/InMemoryBikeRepository.cs
@@ -0,0 +1,112 @@
+using Bikes.Domain.Models;
+using Bikes.Domain.Repositories;
+using Bikes.Tests;
+
+namespace Bikes.Infrastructure.InMemory.Repositories;
+
+///
+/// In-memory implementation of bike repository
+///
+public class InMemoryBikeRepository() : IBikeRepository
+{
+ private readonly List _bikes = [.. new BikesFixture().Bikes];
+ private readonly List _models = [.. new BikesFixture().Models];
+ private readonly List _rents = [.. new BikesFixture().Rents];
+ private readonly List _renters = [.. new BikesFixture().Renters];
+
+ // Bike methods
+ public List GetAllBikes() => [.. _bikes];
+
+ public Bike? GetBikeById(int id) => _bikes.FirstOrDefault(b => b.Id == id);
+
+ public void AddBike(Bike bike)
+ {
+ ArgumentNullException.ThrowIfNull(bike);
+ _bikes.Add(bike);
+ }
+
+ public void UpdateBike(Bike bike)
+ {
+ ArgumentNullException.ThrowIfNull(bike);
+ var existingBike = _bikes.FirstOrDefault(b => b.Id == bike.Id);
+ if (existingBike != null)
+ {
+ _bikes.Remove(existingBike);
+ _bikes.Add(bike);
+ }
+ }
+
+ public void DeleteBike(int id) => _bikes.RemoveAll(b => b.Id == id);
+
+ // BikeModel methods
+ public List GetAllModels() => [.. _models];
+
+ public BikeModel? GetModelById(int id) => _models.FirstOrDefault(m => m.Id == id);
+
+ public void AddModel(BikeModel model)
+ {
+ ArgumentNullException.ThrowIfNull(model);
+ _models.Add(model);
+ }
+
+ public void UpdateModel(BikeModel model)
+ {
+ ArgumentNullException.ThrowIfNull(model);
+ var existingModel = _models.FirstOrDefault(m => m.Id == model.Id);
+ if (existingModel != null)
+ {
+ _models.Remove(existingModel);
+ _models.Add(model);
+ }
+ }
+
+ public void DeleteModel(int id) => _models.RemoveAll(m => m.Id == id);
+
+ // Renter methods
+ public List GetAllRenters() => [.. _renters];
+
+ public Renter? GetRenterById(int id) => _renters.FirstOrDefault(r => r.Id == id);
+
+ public void AddRenter(Renter renter)
+ {
+ ArgumentNullException.ThrowIfNull(renter);
+ _renters.Add(renter);
+ }
+
+ public void UpdateRenter(Renter renter)
+ {
+ ArgumentNullException.ThrowIfNull(renter);
+ var existingRenter = _renters.FirstOrDefault(r => r.Id == renter.Id);
+ if (existingRenter != null)
+ {
+ _renters.Remove(existingRenter);
+ _renters.Add(renter);
+ }
+ }
+
+ public void DeleteRenter(int id) => _renters.RemoveAll(r => r.Id == id);
+
+ // Rent methods
+ public List GetAllRents() => [.. _rents];
+
+ public Rent? GetRentById(int id) => _rents.FirstOrDefault(r => r.Id == id);
+
+ public void AddRent(Rent rent)
+ {
+ ArgumentNullException.ThrowIfNull(rent);
+ _rents.Add(rent);
+ }
+
+ public void UpdateRent(Rent rent)
+ {
+ ArgumentNullException.ThrowIfNull(rent);
+ var existingRent = _rents.FirstOrDefault(r => r.Id == rent.Id);
+ if (existingRent != null)
+ {
+ _rents.Remove(existingRent);
+ _rents.Add(rent);
+ }
+ }
+
+ public void DeleteRent(int id) => _rents.RemoveAll(r => r.Id == id);
+}
\ No newline at end of file
diff --git a/Bikes.Infrastructure.Kafka/Bikes.Infrastructure.Kafka.csproj b/Bikes.Infrastructure.Kafka/Bikes.Infrastructure.Kafka.csproj
new file mode 100644
index 000000000..ae5c34526
--- /dev/null
+++ b/Bikes.Infrastructure.Kafka/Bikes.Infrastructure.Kafka.csproj
@@ -0,0 +1,28 @@
+
+
+
+ net8.0
+ enable
+ enable
+ dotnet-Bikes.Infrastructure.Kafka-33d6809d-7458-4668-a600-1d82cb896a7d
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Bikes.Infrastructure.Kafka/Deserializers/KeyDeserializer.cs b/Bikes.Infrastructure.Kafka/Deserializers/KeyDeserializer.cs
new file mode 100644
index 000000000..495bcb582
--- /dev/null
+++ b/Bikes.Infrastructure.Kafka/Deserializers/KeyDeserializer.cs
@@ -0,0 +1,16 @@
+using Confluent.Kafka;
+using System.Text;
+
+namespace Bikes.Infrastructure.Kafka.Deserializers;
+
+public class KeyDeserializer : IDeserializer
+{
+ public Guid Deserialize(ReadOnlySpan data, bool isNull, SerializationContext context)
+ {
+ if (isNull || data.Length == 0)
+ return Guid.Empty;
+
+ var guidString = Encoding.UTF8.GetString(data);
+ return Guid.Parse(guidString);
+ }
+}
\ No newline at end of file
diff --git a/Bikes.Infrastructure.Kafka/Deserializers/ValueDeserializer.cs b/Bikes.Infrastructure.Kafka/Deserializers/ValueDeserializer.cs
new file mode 100644
index 000000000..fa13da0e6
--- /dev/null
+++ b/Bikes.Infrastructure.Kafka/Deserializers/ValueDeserializer.cs
@@ -0,0 +1,27 @@
+using Confluent.Kafka;
+using System.Text;
+using System.Text.Json;
+using Bikes.Application.Contracts.Rents;
+
+namespace Bikes.Infrastructure.Kafka.Deserializers;
+
+public class ValueDeserializer : IDeserializer>
+{
+ public IList Deserialize(ReadOnlySpan data, bool isNull, SerializationContext context)
+ {
+ if (isNull || data.Length == 0)
+ return [];
+
+ var json = Encoding.UTF8.GetString(data);
+
+ try
+ {
+ var result = JsonSerializer.Deserialize>(json);
+ return result ?? [];
+ }
+ catch (JsonException ex)
+ {
+ throw new InvalidOperationException($"Failed to deserialize JSON: {json}", ex);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Bikes.Infrastructure.Kafka/KafkaConsumer.cs b/Bikes.Infrastructure.Kafka/KafkaConsumer.cs
new file mode 100644
index 000000000..854a22828
--- /dev/null
+++ b/Bikes.Infrastructure.Kafka/KafkaConsumer.cs
@@ -0,0 +1,203 @@
+using Confluent.Kafka;
+using Bikes.Application.Contracts.Rents;
+using Bikes.Infrastructure.Kafka.Deserializers;
+
+namespace Bikes.Infrastructure.Kafka;
+
+///
+/// Background service that consumes rent contract messages from Kafka topics
+///
+public class KafkaConsumer : BackgroundService
+{
+ private readonly IConsumer> _consumer;
+ private readonly ILogger _logger;
+ private readonly IServiceScopeFactory _scopeFactory;
+ private readonly string _topic;
+ private readonly string _bootstrapServers;
+
+ public KafkaConsumer(
+ IConfiguration configuration,
+ ILogger logger,
+ KeyDeserializer keyDeserializer,
+ ValueDeserializer valueDeserializer,
+ IServiceScopeFactory scopeFactory)
+ {
+ _logger = logger;
+ _scopeFactory = scopeFactory;
+
+ var kafkaConfig = configuration.GetSection("Kafka");
+ _topic = kafkaConfig["Topic"] ?? "bike-rents";
+
+ _bootstrapServers = configuration.GetConnectionString("bikes-kafka")
+ ?? configuration["Kafka:BootstrapServers"]
+ ?? "localhost:9092";
+
+ var groupId = kafkaConfig["GroupId"] ?? "bikes-consumer-group";
+
+ var consumerConfig = new ConsumerConfig
+ {
+ BootstrapServers = _bootstrapServers,
+ GroupId = groupId,
+ AutoOffsetReset = AutoOffsetReset.Earliest,
+ EnableAutoCommit = true,
+ AllowAutoCreateTopics = true,
+ EnablePartitionEof = true,
+ MaxPollIntervalMs = 300000,
+ SessionTimeoutMs = 45000
+ };
+
+ _consumer = new ConsumerBuilder>(consumerConfig)
+ .SetKeyDeserializer(keyDeserializer)
+ .SetValueDeserializer(valueDeserializer)
+ .SetErrorHandler((_, e) =>
+ {
+ if (e.IsFatal)
+ _logger.LogError("Kafka Fatal Error: {Reason} (Code: {Code})", e.Reason, e.Code);
+ else
+ _logger.LogWarning("Kafka Error: {Reason} (Code: {Code})", e.Reason, e.Code);
+ })
+ .SetLogHandler((_, logMessage) =>
+ {
+ _logger.LogDebug("Kafka Log: {Facility} - {Message}", logMessage.Facility, logMessage.Message);
+ })
+ .Build();
+
+ _logger.LogInformation("Kafka Consumer configured for: {Servers}", _bootstrapServers);
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ _logger.LogInformation("Starting Kafka consumer for topic: {Topic}", _topic);
+
+ await WaitForKafkaAvailableAsync(stoppingToken);
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ try
+ {
+ _consumer.Subscribe(_topic);
+ _logger.LogInformation("Subscribed to Kafka topic: {Topic}", _topic);
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ try
+ {
+ var result = _consumer.Consume(stoppingToken);
+
+ if (result == null)
+ continue;
+
+ if (result.IsPartitionEOF)
+ {
+ _logger.LogDebug("Reached end of partition {Partition}", result.Partition);
+ continue;
+ }
+
+ _logger.LogInformation(
+ "Received message {Key} with {Count} rents (Partition: {Partition}, Offset: {Offset})",
+ result.Message.Key,
+ result.Message.Value?.Count ?? 0,
+ result.Partition,
+ result.Offset
+ );
+
+ if (result.Message.Value != null)
+ {
+ await ProcessMessageAsync(result.Message.Key, result.Message.Value);
+ }
+ }
+ catch (ConsumeException ex) when (ex.Error.Code == ErrorCode.UnknownTopicOrPart)
+ {
+ _logger.LogWarning("Topic {Topic} not available yet, retrying in 5 seconds...", _topic);
+ await Task.Delay(5000, stoppingToken);
+ break;
+ }
+ catch (ConsumeException ex)
+ {
+ _logger.LogError(ex, "Consume error: {Reason} (Code: {Code})", ex.Error.Reason, ex.Error.Code);
+ await Task.Delay(2000, stoppingToken);
+ }
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ _logger.LogInformation("Kafka consumer cancelled");
+ break;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Unexpected error in Kafka consumer");
+ await Task.Delay(5000, stoppingToken);
+ }
+ }
+
+ _logger.LogInformation("Kafka consumer stopped");
+ }
+
+ private async Task WaitForKafkaAvailableAsync(CancellationToken stoppingToken)
+ {
+ _logger.LogInformation("Waiting for Kafka to become available...");
+
+ using var adminClient = new AdminClientBuilder(new AdminClientConfig
+ {
+ BootstrapServers = _bootstrapServers
+ }).Build();
+
+ for (var i = 0; i < 30; i++)
+ {
+ try
+ {
+ var metadata = adminClient.GetMetadata(TimeSpan.FromSeconds(5));
+ _logger.LogInformation("Kafka is available. Brokers: {BrokerCount}", metadata.Brokers.Count);
+ return;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug("Kafka not available yet (attempt {Attempt}/30): {Message}", i + 1, ex.Message);
+ await Task.Delay(5000, stoppingToken);
+ }
+ }
+
+ _logger.LogWarning("Kafka still not available after 150 seconds, continuing anyway...");
+ }
+
+ private async Task ProcessMessageAsync(Guid key, IList rents)
+ {
+ if (rents == null || rents.Count == 0)
+ {
+ _logger.LogWarning("Empty rents list for message {Key}", key);
+ return;
+ }
+
+ try
+ {
+ using var scope = _scopeFactory.CreateScope();
+ var rentService = scope.ServiceProvider.GetRequiredService();
+
+ await rentService.ReceiveContract(rents);
+
+ _logger.LogInformation("Processed message {Key}: {Count} rent contracts saved",
+ key, rents.Count);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error processing message {Key}", key);
+ }
+ }
+
+ public override void Dispose()
+ {
+ try
+ {
+ _consumer?.Close();
+ _consumer?.Dispose();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error disposing Kafka consumer");
+ }
+
+ base.Dispose();
+ GC.SuppressFinalize(this);
+ }
+}
\ No newline at end of file
diff --git a/Bikes.Infrastructure.Kafka/Program.cs b/Bikes.Infrastructure.Kafka/Program.cs
new file mode 100644
index 000000000..4bcf2bc87
--- /dev/null
+++ b/Bikes.Infrastructure.Kafka/Program.cs
@@ -0,0 +1,31 @@
+using Bikes.Infrastructure.Kafka;
+using Bikes.Infrastructure.Kafka.Deserializers;
+using Bikes.ServiceDefaults;
+using Microsoft.EntityFrameworkCore;
+
+var builder = Host.CreateApplicationBuilder(args);
+
+builder.AddServiceDefaults();
+
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+
+builder.Services.AddDbContext(options =>
+{
+ var connectionString = builder.Configuration.GetConnectionString("bikes-db")
+ ?? "Host=localhost;Port=5432;Database=bikes;Username=postgres;Password=postgres";
+ options.UseNpgsql(connectionString);
+});
+
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+
+builder.Services.AddScoped();
+builder.Services.AddScoped(sp =>
+ sp.GetRequiredService());
+
+builder.Services.AddHostedService();
+
+var host = builder.Build();
+await host.RunAsync();
\ No newline at end of file
diff --git a/Bikes.Infrastructure.Kafka/Properties/launchSettings.json b/Bikes.Infrastructure.Kafka/Properties/launchSettings.json
new file mode 100644
index 000000000..f3b7eff27
--- /dev/null
+++ b/Bikes.Infrastructure.Kafka/Properties/launchSettings.json
@@ -0,0 +1,11 @@
+{
+ "profiles": {
+ "Bikes.Infrastructure.Kafka": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "environmentVariables": {
+ "DOTNET_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Bikes.Infrastructure.Kafka/appsettings.Development.json b/Bikes.Infrastructure.Kafka/appsettings.Development.json
new file mode 100644
index 000000000..b2dcdb674
--- /dev/null
+++ b/Bikes.Infrastructure.Kafka/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.Hosting.Lifetime": "Information"
+ }
+ }
+}
diff --git a/Bikes.Infrastructure.Kafka/appsettings.json b/Bikes.Infrastructure.Kafka/appsettings.json
new file mode 100644
index 000000000..d9d2d78ce
--- /dev/null
+++ b/Bikes.Infrastructure.Kafka/appsettings.json
@@ -0,0 +1,12 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.Hosting.Lifetime": "Information"
+ }
+ },
+ "Kafka": {
+ "Topic": "bike-rents",
+ "GroupId": "bikes-consumer-group"
+ }
+}
\ No newline at end of file
diff --git a/Bikes.ServiceDefaults/Bikes.ServiceDefaults.csproj b/Bikes.ServiceDefaults/Bikes.ServiceDefaults.csproj
new file mode 100644
index 000000000..2ce061c99
--- /dev/null
+++ b/Bikes.ServiceDefaults/Bikes.ServiceDefaults.csproj
@@ -0,0 +1,19 @@
+
+
+ net8.0
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Bikes.ServiceDefaults/ServiceDefaultsExtensions.cs b/Bikes.ServiceDefaults/ServiceDefaultsExtensions.cs
new file mode 100644
index 000000000..267a490fd
--- /dev/null
+++ b/Bikes.ServiceDefaults/ServiceDefaultsExtensions.cs
@@ -0,0 +1,123 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Diagnostics.HealthChecks;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using OpenTelemetry;
+using OpenTelemetry.Metrics;
+using OpenTelemetry.Trace;
+
+namespace Bikes.ServiceDefaults;
+
+///
+/// Extensions for configuring default .NET Aspire services.
+///
+public static class ServiceDefaultsExtensions
+{
+ ///
+ /// Adds default .NET Aspire services: OpenTelemetry, health checks, service discovery, and HTTP client resilience.
+ ///
+ /// The host application builder type.
+ /// The host builder.
+ /// The host builder for chaining.
+ public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder
+ {
+ builder.ConfigureOpenTelemetry();
+ builder.AddDefaultHealthChecks();
+ builder.Services.AddServiceDiscovery();
+
+ builder.Services.ConfigureHttpClientDefaults(http =>
+ {
+ http.AddStandardResilienceHandler();
+ http.AddServiceDiscovery();
+ });
+
+ return builder;
+ }
+
+ ///
+ /// Configures OpenTelemetry for metrics, tracing, and logging collection.
+ ///
+ /// The host application builder type.
+ /// The host builder.
+ /// The host builder for chaining.
+ public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder
+ {
+ builder.Logging.AddOpenTelemetry(logging =>
+ {
+ logging.IncludeFormattedMessage = true;
+ logging.IncludeScopes = true;
+ });
+
+ builder.Services.AddOpenTelemetry()
+ .WithMetrics(metrics =>
+ {
+ metrics.AddAspNetCoreInstrumentation()
+ .AddHttpClientInstrumentation()
+ .AddRuntimeInstrumentation();
+ })
+ .WithTracing(tracing =>
+ {
+ tracing.AddSource(builder.Environment.ApplicationName)
+ .AddAspNetCoreInstrumentation()
+ .AddHttpClientInstrumentation();
+ });
+
+ builder.AddOpenTelemetryExporters();
+
+ return builder;
+ }
+
+ ///
+ /// Adds OpenTelemetry exporters if OTLP endpoint is configured.
+ ///
+ /// The host application builder type.
+ /// The host builder.
+ /// The host builder for chaining.
+ private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder
+ {
+ var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
+
+ if (useOtlpExporter)
+ {
+ builder.Services.AddOpenTelemetry().UseOtlpExporter();
+ }
+
+ return builder;
+ }
+
+ ///
+ /// Adds default health checks for the application.
+ ///
+ /// The host application builder type.
+ /// The host builder.
+ /// The host builder for chaining.
+ public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder
+ {
+ builder.Services.AddHealthChecks()
+ .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);
+
+ return builder;
+ }
+
+ ///
+ /// Maps default health check endpoints for the web application.
+ ///
+ /// The web application.
+ /// The web application for chaining.
+ public static WebApplication MapDefaultEndpoints(this WebApplication app)
+ {
+ if (app.Environment.IsDevelopment())
+ {
+ app.MapHealthChecks("/alive", new HealthCheckOptions
+ {
+ Predicate = r => r.Tags.Contains("live")
+ });
+
+ app.MapHealthChecks("/health");
+ }
+
+ return app;
+ }
+}
\ No newline at end of file
diff --git a/Bikes.Tests/Bikes.Tests.csproj b/Bikes.Tests/Bikes.Tests.csproj
new file mode 100644
index 000000000..3a1bf7982
--- /dev/null
+++ b/Bikes.Tests/Bikes.Tests.csproj
@@ -0,0 +1,27 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Bikes.Tests/BikesFixture.cs b/Bikes.Tests/BikesFixture.cs
new file mode 100644
index 000000000..d94d5934e
--- /dev/null
+++ b/Bikes.Tests/BikesFixture.cs
@@ -0,0 +1,183 @@
+using Bikes.Domain.Models;
+
+namespace Bikes.Tests;
+
+///
+/// Fixture for bike rental tests providing test data
+///
+public class BikesFixture
+{
+ ///
+ /// Gets the list of bike models
+ ///
+ public List Models { get; }
+
+ ///
+ /// Gets the list of bikes
+ ///
+ public List Bikes { get; }
+
+ ///
+ /// Gets the list of renters
+ ///
+ public List Renters { get; }
+
+ ///
+ /// Gets the list of rental records
+ ///
+ public List Rents { get; }
+
+ ///
+ /// Initializes a new instance of the BikesFixture class
+ ///
+ public BikesFixture()
+ {
+ Models = InitializeModels();
+ Bikes = InitializeBikes(Models);
+ Renters = InitializeRenters();
+ Rents = InitializeRents(Bikes, Renters);
+ }
+
+ ///
+ /// Initializes the list of bike models
+ ///
+ /// List of bike models
+ private static List InitializeModels()
+ {
+ return
+ [
+ new() { Id = 1, Name = "Sport Pro 1000", Type = BikeType.Sport, WheelSize = 28, MaxWeight = 120, Weight = 10, BrakeType = "Disc", ModelYear = 2023, PricePerHour = 15 },
+ new() { Id = 2, Name = "Mountain Extreme", Type = BikeType.Mountain, WheelSize = 29, MaxWeight = 130, Weight = 12, BrakeType = "Hydraulic", ModelYear = 2023, PricePerHour = 12 },
+ new() { Id = 3, Name = "Road Racer", Type = BikeType.Road, WheelSize = 26, MaxWeight = 110, Weight = 8, BrakeType = "Rim", ModelYear = 2023, PricePerHour = 10 },
+ new() { Id = 4, Name = "Sport Elite", Type = BikeType.Sport, WheelSize = 27.5m, MaxWeight = 125, Weight = 11, BrakeType = "Disc", ModelYear = 2023, PricePerHour = 14 },
+ new() { Id = 5, Name = "Hybrid Comfort", Type = BikeType.Hybrid, WheelSize = 28, MaxWeight = 135, Weight = 13, BrakeType = "Rim", ModelYear = 2023, PricePerHour = 8 },
+ new() { Id = 6, Name = "Electric City", Type = BikeType.Electric, WheelSize = 26, MaxWeight = 140, Weight = 20, BrakeType = "Disc", ModelYear = 2023, PricePerHour = 20 },
+ new() { Id = 7, Name = "Sport Lightning", Type = BikeType.Sport, WheelSize = 29, MaxWeight = 115, Weight = 9.5m, BrakeType = "Hydraulic", ModelYear = 2023, PricePerHour = 16 },
+ new() { Id = 8, Name = "Mountain King", Type = BikeType.Mountain, WheelSize = 27.5m, MaxWeight = 128, Weight = 11.5m, BrakeType = "Disc", ModelYear = 2023, PricePerHour = 13 },
+ new() { Id = 9, Name = "Road Speed", Type = BikeType.Road, WheelSize = 28, MaxWeight = 105, Weight = 7.5m, BrakeType = "Rim", ModelYear = 2023, PricePerHour = 11 },
+ new() { Id = 10, Name = "Sport Thunder", Type = BikeType.Sport, WheelSize = 26, MaxWeight = 122, Weight = 10.5m, BrakeType = "Disc", ModelYear = 2023, PricePerHour = 15.5m },
+ new() { Id = 11, Name = "Electric Mountain", Type = BikeType.Electric, WheelSize = 29, MaxWeight = 145, Weight = 22, BrakeType = "Hydraulic", ModelYear = 2023, PricePerHour = 25 }
+ ];
+ }
+
+ ///
+ /// Initializes the list of bikes
+ ///
+ /// List of bike models
+ /// List of bikes
+ private static List InitializeBikes(List models)
+ {
+ var bikes = new List();
+ var colors = new[] { "Red", "Blue", "Green", "Black", "White", "Yellow" };
+
+ var bikeConfigurations = new[]
+ {
+ new { ModelIndex = 0, ColorIndex = 0 },
+ new { ModelIndex = 1, ColorIndex = 1 },
+ new { ModelIndex = 2, ColorIndex = 2 },
+ new { ModelIndex = 3, ColorIndex = 3 },
+ new { ModelIndex = 4, ColorIndex = 4 },
+ new { ModelIndex = 5, ColorIndex = 5 },
+ new { ModelIndex = 6, ColorIndex = 0 },
+ new { ModelIndex = 7, ColorIndex = 1 },
+ new { ModelIndex = 8, ColorIndex = 2 },
+ new { ModelIndex = 9, ColorIndex = 3 },
+ new { ModelIndex = 10, ColorIndex = 4 },
+ new { ModelIndex = 0, ColorIndex = 5 },
+ new { ModelIndex = 1, ColorIndex = 0 },
+ new { ModelIndex = 2, ColorIndex = 1 },
+ new { ModelIndex = 3, ColorIndex = 2 }
+ };
+
+ for (var i = 0; i < bikeConfigurations.Length; i++)
+ {
+ var config = bikeConfigurations[i];
+ var model = models[config.ModelIndex];
+ var color = colors[config.ColorIndex];
+
+ bikes.Add(new Bike
+ {
+ Id = i + 1,
+ SerialNumber = $"SN{(i + 1):D6}",
+ Model = model,
+ Color = color,
+ IsAvailable = i % 2 == 0
+ });
+ }
+
+ return bikes;
+ }
+
+ ///
+ /// Initializes the list of renters
+ ///
+ /// List of renters
+ private static List InitializeRenters()
+ {
+ return
+ [
+ new() { Id = 1, FullName = "Ivanov Ivan", Phone = "+79111111111" },
+ new() { Id = 2, FullName = "Petrov Petr", Phone = "+79112222222" },
+ new() { Id = 3, FullName = "Sidorov Alexey", Phone = "+79113333333" },
+ new() { Id = 4, FullName = "Kuznetsova Maria", Phone = "+79114444444" },
+ new() { Id = 5, FullName = "Smirnov Dmitry", Phone = "+79115555555" },
+ new() { Id = 6, FullName = "Vasilyeva Ekaterina", Phone = "+79116666666" },
+ new() { Id = 7, FullName = "Popov Artem", Phone = "+79117777777" },
+ new() { Id = 8, FullName = "Lebedeva Olga", Phone = "+79118888888" },
+ new() { Id = 9, FullName = "Novikov Sergey", Phone = "+79119999999" },
+ new() { Id = 10, FullName = "Morozova Anna", Phone = "+79110000000" },
+ new() { Id = 11, FullName = "Volkov Pavel", Phone = "+79121111111" },
+ new() { Id = 12, FullName = "Sokolova Irina", Phone = "+79122222222" }
+ ];
+ }
+
+ ///