A vehicle rental system is a good example of how different parts of a .NET application work together. It involves user authentication, customer management, vehicle availability, rental operations, database communication, validation, and session management.
In this article, we will look at how to build the core parts of a console-based vehicle rental application using C#, Entity Framework Core, SQL Server, and a layered architecture.
The application separates responsibilities into different layers. The business logic is handled by services, database operations are handled by repositories, and Entity Framework Core is used to communicate with SQL Server.
The application supports features such as:
User registration and login
Customer management
Vehicle management
Vehicle rental
Vehicle return
Rental validation
Session management
Email sending
Entity Framework Core migrations
SQL Server database connectivity
Console-based menus
Application Architecture
The application follows a layered structure:
Console Application
|
v
Business Logic Layer (BLL)
|
v
Data Access Layer (DAL)
|
v
Entity Framework Core
|
v
SQL Server Database
The main responsibilities of the layers are:
Layer | Responsibility |
|---|
Console/UI | Displays menus and receives user input |
BLL | Contains application and business rules |
DAL | Handles database operations |
Entity Framework Core | Maps C# objects to database tables |
SQL Server | Stores application data |
Shared | Contains common models, enums, and result objects |
Utilities | Contains reusable validation and helper functionality |
This separation makes it easier to maintain the application because each layer has a specific responsibility.
Installing Entity Framework Core Packages
The first step is to install the Entity Framework Core packages required by the application.
Using the Package Manager Console, run:
Install-Package Microsoft.EntityFrameworkCore
Install-Package Microsoft.EntityFrameworkCore.SqlServer
Install-Package Microsoft.EntityFrameworkCore.Tools
Install-Package Microsoft.EntityFrameworkCore.Design
The packages are used for different purposes.
Microsoft.EntityFrameworkCore provides the core EF Core functionality.
Microsoft.EntityFrameworkCore.SqlServer provides SQL Server support.
Microsoft.EntityFrameworkCore.Tools provides commands such as Add-Migration and Update-Database.
Microsoft.EntityFrameworkCore.Design provides design-time functionality required by EF Core tooling.
Reading the Database Connection from appsettings.json
Instead of placing the SQL Server connection string directly inside the DbContext, the application can store it in appsettings.json.
The configuration-related packages can be installed using:
Install-Package Microsoft.Extensions.Configuration
Install-Package Microsoft.Extensions.Configuration.Json
Install-Package Microsoft.Extensions.Configuration.FileExtensions
Install-Package Microsoft.Extensions.FileProviders.Physical
The connection string should contain placeholders when publishing an article.
{
"ConnectionStrings": {
"DefaultConnection": "Data Source=YOUR_SERVER;Initial Catalog=YOUR_DATABASE;User ID=YOUR_USER;Password=YOUR_PASSWORD;TrustServerCertificate=True;"
}
}
Never publish real database credentials in source code or an article.
The DefaultConnection name is later used by the DbContext to retrieve the connection string.
Creating the AppDbContext
The AppDbContext class inherits from DbContext and represents the database session used by Entity Framework Core.
using DAL.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
namespace DAL.Context
{
public class AppDbContext : DbContext
{
public DbSet<User> Users { get; set; }
public DbSet<Customer> Customers { get; set; }
public DbSet<Vehicle> Vehicles { get; set; }
public DbSet<Rental> Rentals { get; set; }
protected override void OnConfiguring(
DbContextOptionsBuilder optionsBuilder)
{
var config = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json")
.Build();
string? connection =
config.GetConnectionString("DefaultConnection");
if (string.IsNullOrEmpty(connection))
{
throw new Exception("Connection string not found.");
}
optionsBuilder.UseSqlServer(connection);
}
}
}
Each DbSet<T> represents an entity that Entity Framework Core maps to a database table.
For example:
public DbSet<Vehicle> Vehicles { get; set; }
allows the application to query and manage vehicle records through Entity Framework Core.
Configuring Entity Relationships
The application contains relationships between users, customers, vehicles, and rentals.
These relationships can be configured in the OnModelCreating() method.
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<Customer>()
.HasOne(c => c.User)
.WithOne(u => u.Customer)
.HasForeignKey<Customer>(c => c.UserId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Rental>()
.HasOne(r => r.Customer)
.WithMany(c => c.Rentals)
.HasForeignKey(r => r.CustomerId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Rental>()
.HasOne(r => r.Vehicle)
.WithMany(v => v.Rentals)
.HasForeignKey(r => r.VehicleId)
.OnDelete(DeleteBehavior.Restrict);
}
There are three important relationships here.
User and Customer
A user has a corresponding customer record, creating a one-to-one relationship.
.HasOne(c => c.User)
.WithOne(u => u.Customer)
The UserId property is used as the foreign key in the Customer entity.
Customer and Rental
A customer can have multiple rental records.
.HasOne(r => r.Customer)
.WithMany(c => c.Rentals)
This creates a one-to-many relationship.
Vehicle and Rental
A vehicle can be associated with multiple rental records over time.
.HasOne(r => r.Vehicle)
.WithMany(v => v.Rentals)
DeleteBehavior.Restrict prevents related records from being automatically deleted when a referenced record is deleted.
Adding Seed Data
Entity Framework Core can also insert initial data into the database through HasData().
For example:
modelBuilder.Entity<User>().HasData(
new User
{
Id = 1,
Email = "[email protected]",
Password = "CHANGE_ME",
IsAdmin = true
},
new User
{
Id = 2,
Email = "[email protected]",
Password = "CHANGE_ME",
IsAdmin = false
}
);
For demonstration purposes, seed data can be useful during development.
However, passwords should never be stored as plain text in a real application. Passwords should be securely hashed before being stored in the database.
Vehicle records can also be seeded:
modelBuilder.Entity<Vehicle>().HasData(
new Vehicle
{
Id = 1,
Name = Name.HondaCity,
Category = Category.Car,
RentPerDay = 2500,
FuelType = FuelType.Petrol,
TotalUnits = 5,
AvailableUnits = 5
}
);
Creating the Database with EF Core Migrations
Once the DbContext and entities are configured, create the initial migration using:
Add-Migration InitialCreate
The migration contains the database schema changes detected by Entity Framework Core.
To apply the migration to SQL Server, run:
Update-Database
This creates or updates the database based on the migration.
The basic workflow is:
Create/Update Entity
|
v
Add-Migration
|
v
Update-Database
|
v
SQL Server
Managing User Sessions
Because this is a console application, the application uses a simple session manager to keep track of the currently logged-in user.
public static User? CurrentUser { get; private set; }
public static void Login(User user)
{
CurrentUser = user;
}
public static void Logout()
{
CurrentUser = null;
}
public static bool IsLoggedIn()
{
return CurrentUser != null;
}
public static bool IsAdmin =>
CurrentUser?.IsAdmin ?? false;
When login succeeds, the current user is stored in CurrentUser.
When the user logs out, the value is set to null.
The IsLoggedIn() method can then be used to determine whether the user has an active session.
For a simple console application this approach is straightforward. It should not be treated as a replacement for a proper authentication and authorization mechanism in a web or distributed production application.
Validating Email and Password
The application uses a regular expression to perform basic email validation.
public class EmailValidator
{
private static readonly Regex _regex =
new Regex(
@"^[^@\s]+@[^@\s]+\.[^@\s]+$",
RegexOptions.Compiled |
RegexOptions.IgnoreCase);
public static string? ValidateEmail(string email)
{
if (string.IsNullOrEmpty(email))
{
return Messages.EmptyEmail;
}
if (!_regex.IsMatch(email))
{
return Messages.InvalidEmail;
}
return null;
}
public static string? ValidatePass(string pass)
{
if (string.IsNullOrEmpty(pass))
{
return Messages.EmptyPass;
}
return null;
}
}
The email validation checks whether the value is empty and whether it follows the expected email pattern.
The password validation in this example only checks whether the password is empty. A production application should use stronger password requirements and secure password hashing.
Creating a Result Wrapper
The application uses a generic Result<T> class to return both the operation status and the associated data.
public class Result<T>
{
public bool Success { get; set; }
public string Message { get; set; } = string.Empty;
public T? Data { get; set; }
}
This provides a consistent structure for service responses.
For example:
return new Result<bool>
{
Success = false,
Message = "Vehicle is not available.",
Data = false
};
Instead of returning only a Boolean value, the service can also provide a meaningful message.
Implementing User Services
The UserServices class contains the business logic related to vehicle rentals and returns.
It depends on repositories for vehicles, customers, and rentals.
public class UserServices : IUserServices
{
private readonly IVehicleRepository _vehicleRepo;
private readonly ICustomerRepository _customerRepo;
private readonly IRentalRepository _rentalRepo;
public UserServices()
{
_vehicleRepo = new VehicleRepository();
_customerRepo = new CustomerRepository();
_rentalRepo = new RentalRepository();
}
}
The service does not directly perform database queries. Instead, it communicates with repository classes.
This keeps database access separate from business rules.
Getting Available Vehicles
The service can retrieve vehicle records through the vehicle repository.
public async Task<Result<List<Vehicle>>>
GetAllAvailableVehiclesAsync()
{
var vehicles = await _vehicleRepo.GetAllVehiclesAsync();
if (vehicles.Count == 0)
{
return new Result<List<Vehicle>>
{
Success = false,
Message = "No available vehicles found.",
Data = vehicles
};
}
return new Result<List<Vehicle>>
{
Success = true,
Message = "Available vehicles retrieved successfully.",
Data = vehicles
};
}
The method returns a Result<List<Vehicle>>, allowing the caller to determine whether the operation succeeded and retrieve the vehicle list.
If the repository is intended to return only vehicles with available units, that filtering should be implemented explicitly in the repository query.
Renting a Vehicle
The RentVehicleAsync() method contains the main business rules for renting a vehicle.
First, the number of rental days is validated.
if (rentalDays < 1 || rentalDays > 30)
{
return new Result<bool>
{
Success = false,
Message = "Rental days must be between 1 and 30.",
Data = false
};
}
Next, the current user is retrieved from the session.
int userId = SessionManager.CurrentUser!.Id;
var customer =
await _customerRepo.GetCustomerByUserIdAsync(userId);
If the customer does not exist, the operation stops.
The selected vehicle is then retrieved.
var vehicle =
await _vehicleRepo.GetVehicleByIdAsync(vehicleId);
if (vehicle == null)
{
return new Result<bool>
{
Success = false,
Message = "Vehicle not found.",
Data = false
};
}
The application also checks whether the vehicle has available units.
if (vehicle.AvailableUnits <= 0)
{
return new Result<bool>
{
Success = false,
Message = "Vehicle is not available.",
Data = false
};
}
The service then checks the customer's active rental count.
int activeRentalCount =
await _rentalRepo.GetActiveRentalCountAsync(customer.Id);
if (activeRentalCount >= 2)
{
return new Result<bool>
{
Success = false,
Message = "You cannot have more than 2 active rentals.",
Data = false
};
}
It also prevents the customer from renting the same vehicle while an existing rental is active.
bool alreadyRented =
await _rentalRepo.HasActiveRentalForVehicleAsync(
customer.Id,
vehicleId);
if (alreadyRented)
{
return new Result<bool>
{
Success = false,
Message = "You have already rented this vehicle.",
Data = false
};
}
Once all validations pass, the rental amount is calculated.
decimal baseAmount =
vehicle.RentPerDay * rentalDays;
A new Rental object is then created.
Rental rental = new Rental
{
CustomerId = customer.Id,
VehicleId = vehicle.Id,
RentedOn = DateTime.Now,
DueDate = DateTime.Now.AddDays(rentalDays),
RentalDays = rentalDays,
BaseAmount = baseAmount,
PenaltyAmount = 0,
Status = Status.active
};
The available vehicle count is reduced by one:
vehicle.AvailableUnits--;
Finally, both the rental and vehicle are updated.
await _rentalRepo.AddRentalAsync(rental);
await _vehicleRepo.UpdateVehicleAsync(vehicle);
The method returns a successful result:
return new Result<bool>
{
Success = true,
Message = "Vehicle rented successfully.",
Data = true
};
Returning a Vehicle
The ReturnVehicleAsync() method handles the return process.
First, the rental is retrieved.
var rental =
await _rentalRepo.GetRentalByIdAsync(rentalId);
if (rental == null)
{
return new Result<bool>
{
Success = false,
Message = "Rental not found.",
Data = false
};
}
The application then checks whether the rental is still active.
if (rental.Status != Status.active)
{
return new Result<bool>
{
Success = false,
Message = "Only active rentals can be returned.",
Data = false
};
}
The rental is marked as returned.
rental.ReturnedOn = DateTime.Now;
rental.Status = Status.returned;
The associated vehicle is retrieved and its available unit count is increased.
var vehicle =
await _vehicleRepo.GetVehicleByIdAsync(rental.VehicleId);
if (vehicle != null)
{
vehicle.AvailableUnits++;
await _vehicleRepo.UpdateVehicleAsync(vehicle);
}
Finally, the rental record is updated.
await _rentalRepo.UpdateRentalAsync(rental);
This keeps the rental status and vehicle availability synchronized.
Viewing Customer Rentals
The current customer's rentals can be retrieved using the session information.
public async Task<List<Rental>> ViewMyRentalsAsync()
{
int userID = SessionManager.CurrentUser!.Id;
var customer =
await _customerRepo.GetCustomerByUserIdAsync(userID);
if (customer == null)
{
return new List<Rental>();
}
return await _rentalRepo
.GetRentalsByCustomerIdAsync(customer.Id);
}
The current user's ID is obtained from SessionManager, and the corresponding customer record is then used to retrieve rental history.
Creating the Console Menu
The Program.cs file controls the main application flow.
The application first checks whether a user is logged in.
while (true)
{
if (!SessionManager.IsLoggedIn())
{
// Public menu
}
else
{
// Logged-in user menu
}
}
For users who are not logged in, the application provides options such as login, registration, and quitting the application.
After successful authentication, the application can display different options based on the user's role.
A regular user can access operations such as:
View Available Vehicles
Rent a Vehicle
Return a Vehicle
View My Rentals
Logout
An administrator can be provided with administrative operations according to the application's requirements.
Adding Console UI Packages
The application can use Spectre.Console for a more structured console interface.
Install it using:
Install-Package Spectre.Console
For example:
AnsiConsole.MarkupLine(
"[green]Welcome to Vehicle Rental System[/]");
The package can also be used to create selection menus.
var choice = AnsiConsole.Prompt(
new SelectionPrompt<string>()
.Title("Choose an option")
.AddChoices(
"View Available Vehicles",
"Rent a Vehicle",
"Return a Vehicle",
"View My Rentals",
"Logout"));
This provides a menu-based interface instead of requiring the user to enter numeric choices manually.
Sending Email Notifications
The application defines an interface for email sending:
public interface IEmailSender
{
Task SendEmailAsync(
string toEmail,
string subject,
string body);
}
An implementation can use SMTP to send the email.
For example:
public class EmailSender : IEmailSender
{
public async Task SendEmailAsync(
string toEmail,
string subject,
string body)
{
var smtp = new SmtpClient(
"smtp.example.com",
587)
{
Credentials = new NetworkCredential(
"YOUR_EMAIL",
"YOUR_PASSWORD"),
EnableSsl = true
};
var message = new MailMessage(
"YOUR_EMAIL",
toEmail,
subject,
body);
await smtp.SendMailAsync(message);
}
}
Credentials should never be hard-coded in source code. In a real application, SMTP settings should be stored securely using configuration, environment variables, or a suitable secret-management solution.
Application Workflow
The main vehicle rental workflow can be summarized as follows:
Start the console application.
Display the public menu.
Register a new user or log in.
Store the authenticated user in the session manager.
Display user-specific options.
Retrieve available vehicles from the database.
Select a vehicle and rental duration.
Validate the rental request.
Create a rental record.
Reduce the available vehicle count.
Return the vehicle when the rental is completed.
Update the rental status.
Increase the vehicle's available unit count.
Log out when the session is finished.
Important Production Considerations
The supplied implementation demonstrates the core application flow, but some areas require additional work before using a similar design in a production system.
Password Security
Passwords should never be stored as plain text. Use a secure password hashing mechanism and never include real passwords in seed data or source control.
Database Transactions
The rental operation changes more than one piece of data. Both the rental record and vehicle availability should be updated consistently. A database transaction can be considered to prevent one operation from succeeding while the other fails.
Connection String Security
Database usernames and passwords should not be committed to source control or published in articles.
Authorization
Checking whether a user is an administrator should be backed by proper authorization rules at the service or application boundary.
Error Handling
The current examples primarily handle expected validation failures. Production applications should also handle database exceptions, connection failures, and unexpected errors.
Date and Time Handling
The example uses DateTime.Now. Applications that operate across different time zones should consider a consistent date and time strategy, such as storing UTC timestamps where appropriate.
Common Problems and Troubleshooting
Migration Command Is Not Recognized
Make sure the Entity Framework Core tools package is installed:
Install-Package Microsoft.EntityFrameworkCore.Tools
Then rebuild the project and run:
Add-Migration InitialCreate
Connection String Not Found
Verify that:
appsettings.json exists.
The file is copied to the application's output directory when required.
The connection string is named DefaultConnection.
The connection string contains valid database settings.
Database Connection Fails
Check the SQL Server instance name, database name, authentication credentials, and network connectivity.
Vehicle Availability Becomes Incorrect
Rental and return operations update AvailableUnits. These changes should be performed consistently and, for concurrent applications, should be protected against conflicting updates.
Advantages
This architecture provides several benefits:
Separates business logic from database access.
Makes the application easier to maintain.
Uses Entity Framework Core for database operations.
Provides reusable service and repository classes.
Supports asynchronous database operations.
Centralizes session information.
Provides reusable result objects.
Allows the console interface to be changed without moving business rules into the UI layer.
Disadvantages
There are also some limitations:
The current session manager is suitable mainly for a simple console application.
Direct object creation of repositories and services makes dependency injection more difficult.
Error handling needs to be expanded for production use.
Password handling needs stronger security.
Database transactions should be considered for operations that update multiple records.
Some validation is performed only at the application level and should also be enforced where appropriate at the database or service level.
Conclusion
A vehicle rental system is a useful example for understanding how a C# application can be divided into multiple layers. In this implementation, the console application handles user interaction, the business layer contains rental rules, repositories handle data access, Entity Framework Core communicates with SQL Server, and the session manager keeps track of the logged-in user.
The application also demonstrates several common .NET concepts, including Entity Framework Core migrations, database relationships, seed data, asynchronous methods, validation, generic result wrappers, and repository-based data access.
The implementation provides a foundation for a vehicle rental application. Before using the same approach in a production system, additional attention should be given to password hashing, secure configuration, authorization, transaction handling, error handling, and concurrency.
Join the conversation! Your thoughts help the community grow.