Developing a Splitwise-Like MVP with C# and Blazor
Comprehensive Guide to Building Web and Mobile Expense Sharing Applications
Key Takeaways
- User Authentication: Implement secure registration and login using ASP.NET Core Identity.
- Group and Expense Management: Facilitate creation of groups, addition of expenses, and dynamic balance calculations.
- Cross-Platform Deployment: Utilize Blazor and .NET MAUI to develop both web and mobile applications seamlessly.
Introduction
Building a Minimum Viable Product (MVP) of a Splitwise-like application involves creating a platform where users can manage shared expenses within groups. Utilizing C# and Blazor, developers can create both web and mobile applications that mirror the core functionalities of Splitwise. This guide provides a step-by-step approach, integrating best practices and comprehensive strategies to ensure a robust and scalable application.
Core Features of the MVP
1. User Authentication
Secure user authentication is the backbone of the application, ensuring that user data remains protected. Implementing user registration and login functionalities using ASP.NET Core Identity provides a robust framework for managing user credentials and roles.
2. Group Management
Users should be able to create and manage groups, invite other members, and organize expenses within these groups. Each group will have a unique name and a list of enrolled members.
3. Expense Management
Allowing users to add, view, and manage expenses is crucial. Each expense should include details such as description, amount, the user who paid, and how the expense is split among group members.
4. Balance Calculation
The application must calculate and display balances for each user within a group, indicating who owes whom and the corresponding amounts. This feature ensures transparency and ease of settling debts.
5. User Interface
A clean and responsive user interface enhances user experience. Utilizing Blazor components along with UI frameworks like MudBlazor or Radzen can expedite the development process and ensure a consistent look and feel across platforms.
Technical Stack
- Frontend: Blazor (WebAssembly or Server)
- Backend: ASP.NET Core Web API
- Database: SQL Server or SQLite
- Authentication: ASP.NET Core Identity
- UI Framework: MudBlazor or Radzen
Implementation Steps
1. Project Setup
a. Create a New Blazor Project
Use the .NET CLI to initialize a new Blazor WebAssembly or Blazor Server project:
dotnet new blazorwasm -o SplitwiseMVP
b. Configure the Database
Set up SQL Server or SQLite for data storage. Entity Framework Core can be used for database operations. Install necessary NuGet packages:
dotnet add package Microsoft.EntityFrameworkCore
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.AspNetCore.Identity.EntityFrameworkCore
c. Implement ASP.NET Core Identity
ASP.NET Core Identity handles user authentication and authorization. Configure it in your Startup.cs:
services.AddDefaultIdentity<IdentityUser>()
.AddEntityFrameworkStores<ApplicationDbContext>();
2. Defining the Application Architecture
Adopt the MVP (Model-View-Presenter) or MVVM (Model-View-ViewModel) architectural pattern to ensure a clean separation of concerns:
-
Model: Represents the data and business logic (e.g., User, Group, Expense).
-
View: The UI components built with Blazor (.razor files).
-
Presenter/ViewModel: Handles the interaction logic between the view and the model.
3. Developing Core Features
a. User Authentication
Implement registration and login pages using Blazor's EditForm component. Utilize ASP.NET Core Identity for managing user data.
// Register.razor
<EditForm Model="registerModel" OnValidSubmit="HandleRegister">
<InputText @bind-Value="registerModel.Email" placeholder="Email" />
<InputPassword @bind-Value="registerModel.Password" placeholder="Password" />
<button type="submit">Register</button>
</EditForm>
b. Group Management
Create a Group model and implement CRUD operations using a dedicated service.
public class Group
{
public int Id { get; set; }
public string Name { get; set; }
public List<User> Members { get; set; } = new List<User>();
}
public interface IGroupService
{
Task CreateGroup(Group group);
Task<List<Group>> GetUserGroups(string userId);
// Additional methods...
}
public class GroupService : IGroupService
{
// Implementation...
}
c. Expense Management
Define an Expense model and develop functionalities to add, view, and split expenses.
public class Expense
{
public int Id { get; set; }
public string Description { get; set; }
public decimal Amount { get; set; }
public string PaidBy { get; set; }
public Dictionary<string, decimal> SplitAmong { get; set; } = new Dictionary<string, decimal>();
}
public interface IExpenseService
{
Task AddExpense(Expense expense);
Task<List<Expense>> GetExpenses(int groupId);
// Additional methods...
}
public class ExpenseService : IExpenseService
{
// Implementation...
}
d. Balance Calculation
Create a service to calculate and display user balances within a group.
public class BalanceService
{
public Dictionary<string, decimal> CalculateBalances(List<Expense> expenses)
{
var balances = new Dictionary<string, decimal>();
foreach (var expense in expenses)
{
foreach (var (member, amount) in expense.SplitAmong)
{
if (!balances.ContainsKey(member))
balances[member] = 0;
balances[member] -= amount;
}
if (!balances.ContainsKey(expense.PaidBy))
balances[expense.PaidBy] = 0;
balances[expense.PaidBy] += expense.Amount;
}
return balances;
}
}
e. User Interface
Develop responsive UI pages using Blazor components. Incorporate MudBlazor or Radzen for enhanced UI elements.
i. Dashboard
Create a dashboard to display user groups and summary of balances.
<h3>Dashboard</h3>
<div>
@foreach(var group in userGroups)
{
<div class="group-card">
<h4>@group.Name</h4>
<p>Members: @group.Members.Count</p>
</div>
}
</div>
ii. Add Expense
Implement a form to add new expenses within a group.
<EditForm Model="newExpense" OnValidSubmit="AddExpense">
<InputText @bind-Value="newExpense.Description" placeholder="Description" />
<InputNumber @bind-Value="newExpense.Amount" placeholder="Amount" />
<Select @bind-Value="newExpense.PaidBy">
@foreach(var member in groupMembers)
{
<option value="@member.Id">@member.Name</option>
}
</Select>
<button type="submit">Add Expense</button>
</EditForm>
4. Testing
Ensure all functionalities work as intended by implementing unit tests using frameworks like xUnit or NUnit. Test cases should cover user authentication, group and expense management, and balance calculations.
5. Deployment
Deploy the web application to platforms like Azure or AWS. For mobile deployment, use .NET MAUI or Blazor Hybrid to create cross-platform mobile applications.
6. Enhancements
After establishing the MVP, consider adding advanced features such as notifications, payment integrations, and more sophisticated UI/UX enhancements to improve user engagement.
Detailed Implementation
1. Setting Up the Project
a. Initialize Blazor Project
Create a new Blazor WebAssembly project using the .NET CLI:
dotnet new blazorwasm -o SplitwiseMVP
b. Configure Services
In Program.cs, register necessary services:
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddDefaultIdentity<IdentityUser>()
.AddEntityFrameworkStores<ApplicationDbContext>();
builder.Services.AddScoped<IGroupService, GroupService>();
builder.Services.AddScoped<IExpenseService, ExpenseService>();
builder.Services.AddScoped<BalanceService>();
c. Database Context
Define the database context inheriting from IdentityDbContext:
public class ApplicationDbContext : IdentityDbContext
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
public DbSet<Group> Groups { get; set; }
public DbSet<Expense> Expenses { get; set; }
}
2. Implementing User Authentication
a. Registration Page
Develop a registration form using Blazor's EditForm component:
<EditForm Model="registerModel" OnValidSubmit="HandleRegistration">
<DataAnnotationsValidator />
<InputText @bind-Value="registerModel.Email" placeholder="Email" />
<InputPassword @bind-Value="registerModel.Password" placeholder="Password" />
<button type="submit">Register</button>
</EditForm>
@code {
private RegisterModel registerModel = new RegisterModel();
private async Task HandleRegistration()
{
// Registration logic using UserManager
}
}
b. Login Page
Create a login form to authenticate users:
<EditForm Model="loginModel" OnValidSubmit="HandleLogin">
<DataAnnotationsValidator />
<InputText @bind-Value="loginModel.Email" placeholder="Email" />
<InputPassword @bind-Value="loginModel.Password" placeholder="Password" />
<button type="submit">Login</button>
</EditForm>
@code {
private LoginModel loginModel = new LoginModel();
private async Task HandleLogin()
{
// Authentication logic using SignInManager
}
}
3. Group Management
a. Group Model
Define the Group class:
public class Group
{
public int Id { get; set; }
public string Name { get; set; }
public ICollection<IdentityUser> Members { get; set; } = new List<IdentityUser>();
public ICollection<Expense> Expenses { get; set; } = new List<Expense>();
}
b. Group Service
Implement the service to handle group operations:
public class GroupService : IGroupService
{
private readonly ApplicationDbContext _context;
private readonly UserManager<IdentityUser> _userManager;
public GroupService(ApplicationDbContext context, UserManager<IdentityUser> userManager)
{
_context = context;
_userManager = userManager;
}
public async Task CreateGroup(Group group, string userId)
{
var user = await _userManager.FindByIdAsync(userId);
group.Members.Add(user);
_context.Groups.Add(group);
await _context.SaveChangesAsync();
}
public async Task<List<Group>> GetUserGroups(string userId)
{
return await _context.Groups
.Include(g => g.Members)
.Where(g => g.Members.Any(m => m.Id == userId))
.ToListAsync();
}
// Additional methods...
}
4. Expense Management
a. Expense Model
Define the Expense class:
public class Expense
{
public int Id { get; set; }
public string Description { get; set; }
public decimal Amount { get; set; }
public string PaidById { get; set; }
public IdentityUser PaidBy { get; set; }
public int GroupId { get; set; }
public Group Group { get; set; }
public ICollection<ExpenseSplit> Splits { get; set; } = new List<ExpenseSplit>();
}
b. Expense Split Model
Create a model to represent how expenses are split among users:
public class ExpenseSplit
{
public int Id { get; set; }
public int ExpenseId { get; set; }
public Expense Expense { get; set; }
public string UserId { get; set; }
public IdentityUser User { get; set; }
public decimal Amount { get; set; }
}
c. Expense Service
Implement the service to handle expense operations:
public class ExpenseService : IExpenseService
{
private readonly ApplicationDbContext _context;
public ExpenseService(ApplicationDbContext context)
{
_context = context;
}
public async Task AddExpense(Expense expense)
{
_context.Expenses.Add(expense);
await _context.SaveChangesAsync();
}
public async Task<List<Expense>> GetExpenses(int groupId)
{
return await _context.Expenses
.Include(e => e.PaidBy)
.Include(e => e.Splits)
.ThenInclude(s => s.User)
.Where(e => e.GroupId == groupId)
.ToListAsync();
}
// Additional methods...
}
5. Balance Calculation
Implement a service to calculate user balances based on expenses:
public class BalanceService
{
public Dictionary<string, decimal> CalculateBalances(List<Expense> expenses)
{
var balances = new Dictionary<string, decimal>();
foreach (var expense in expenses)
{
foreach (var split in expense.Splits)
{
if (!balances.ContainsKey(split.UserId))
balances[split.UserId] = 0;
balances[split.UserId] -= split.Amount;
}
if (!balances.ContainsKey(expense.PaidById))
balances[expense.PaidById] = 0;
balances[expense.PaidById] += expense.Amount;
}
return balances;
}
}
6. User Interface Development
a. Dashboard Page
Create a dashboard to display user groups and their corresponding balances:
<@page "/dashboard">
<h3>Dashboard</h3>
<div>
@foreach(var group in userGroups)
{
<div class="group-card" @onclick="() => NavigateToGroup(group.Id)">
<h4>@group.Name</h4>
<p>Members: @group.Members.Count</p>
<p>Balance: @GetGroupBalance(group.Id)</p>
</div>
}
</div>
@code {
private List<Group> userGroups;
protected override async Task OnInitializedAsync()
{
userGroups = await GroupService.GetUserGroups(currentUserId);
}
private string GetGroupBalance(int groupId)
{
var expenses = ExpenseService.GetExpenses(groupId);
var balances = BalanceService.CalculateBalances(expenses);
// Format balances for display
}
private void NavigateToGroup(int groupId)
{
NavigationManager.NavigateTo($"/group/{groupId}");
}
}
b. Add Expense Component
Implement a form to add new expenses within a group:
<@page "/group/{GroupId}/add-expense">
<h3>Add Expense</h3>
<EditForm Model="newExpense" OnValidSubmit="HandleAddExpense">
<DataAnnotationsValidator />
<InputText @bind-Value="newExpense.Description" placeholder="Description" />
<InputNumber @bind-Value="newExpense.Amount" placeholder="Amount" />
<Select @bind-Value="newExpense.PaidById">
@foreach(var member in groupMembers)
{
<option value="@member.Id">@member.UserName</option>
}
</Select>
<button type="submit">Add Expense</button>
</EditForm>
@code {
[Parameter]
public int GroupId { get; set; }
private Expense newExpense = new Expense();
private List<IdentityUser> groupMembers;
protected override async Task OnInitializedAsync()
{
groupMembers = await GroupService.GetGroupMembers(GroupId);
}
private async Task HandleAddExpense()
{
newExpense.GroupId = GroupId;
await ExpenseService.AddExpense(newExpense);
NavigationManager.NavigateTo($"/group/{GroupId}");
}
}
7. Testing and Quality Assurance
Implement unit tests to validate the functionality of services and components. Utilizing testing frameworks like xUnit or NUnit ensures the reliability and stability of the application.
// Example of a unit test for BalanceService
public class BalanceServiceTests
{
[Fact]
public void CalculateBalances_ShouldReturnCorrectBalances()
{
// Arrange
var expenses = new List<Expense>()
{
new Expense
{
Amount = 100,
PaidById = "user1",
Splits = new List<ExpenseSplit>()
{
new ExpenseSplit { UserId = "user1", Amount = 50 },
new ExpenseSplit { UserId = "user2", Amount = 50 },
}
},
new Expense
{
Amount = 150,
PaidById = "user2",
Splits = new List<ExpenseSplit>()
{
new ExpenseSplit { UserId = "user1", Amount = 75 },
new ExpenseSplit { UserId = "user2", Amount = 75 },
}
}
};
var balanceService = new BalanceService();
// Act
var balances = balanceService.CalculateBalances(expenses);
// Assert
Assert.Equal(25, balances["user1"]);
Assert.Equal(-25, balances["user2"]);
}
}
8. Deployment
a. Web Application Deployment
Deploy the web application to Azure Static Web Apps or AWS Elastic Beanstalk. Configure CI/CD pipelines for automated deployments.
b. Mobile Application Deployment
Utilize .NET MAUI or Blazor Hybrid to create mobile applications. Deploy to platforms like Google Play Store and Apple App Store.
<!-- Example of configuring MAUI Blazor App -->
public class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
});
builder.Services.AddBlazorWebView();
builder.Services.AddScoped<IGroupService, GroupService>();
builder.Services.AddScoped<IExpenseService, ExpenseService>();
builder.Services.AddScoped<BalanceService>();
return builder.Build();
}
}
Sample Code Snippets
Group Model
Defining the Group entity:
public class Group
{
public int Id { get; set; }
public string Name { get; set; }
public List<string> Members { get; set; } = new List<string>();
}
Expense Model
Defining the Expense entity:
public class Expense
{
public int Id { get; set; }
public string Description { get; set; }
public decimal Amount { get; set; }
public string PaidBy { get; set; }
public Dictionary<string, decimal> SplitAmong { get; set; } = new Dictionary<string, decimal>();
}
Balance Calculation Example
Calculating user balances:
public class BalanceService
{
public Dictionary<string, decimal> CalculateBalances(List<Expense> expenses)
{
var balances = new Dictionary<string, decimal>();
foreach (var expense in expenses)
{
foreach (var (member, amount) in expense.SplitAmong)
{
if (!balances.ContainsKey(member))
balances[member] = 0;
balances[member] -= amount;
}
if (!balances.ContainsKey(expense.PaidBy))
balances[expense.PaidBy] = 0;
balances[expense.PaidBy] += expense.Amount;
}
return balances;
}
}
Blazor Razor Component Example
@page "/add-expense"
<EditForm Model="newExpense" OnValidSubmit="AddExpense">
<InputText @bind-Value="newExpense.Description" placeholder="Expense Description" />
<InputNumber @bind-Value="newExpense.Amount" placeholder="Amount" />
<button type="submit">Add Expense</button>
</EditForm>
@code {
private Expense newExpense = new Expense();
private async Task AddExpense()
{
await ExpenseService.AddExpense(newExpense);
}
}
Conclusion
Developing an MVP for a Splitwise-like application using C# and Blazor involves meticulous planning and execution. By focusing on core functionalities such as user authentication, group and expense management, and balance calculations, developers can create a robust platform. Leveraging Blazor's capabilities for both web and mobile deployment ensures a wide reach and seamless user experience. As the application evolves, integrating advanced features and optimizing performance will further enhance its utility and user satisfaction.
References
By following this comprehensive guide, you can successfully develop a functional MVP of a Splitwise-like application using C# and Blazor, tailored for both web and mobile platforms. This foundation allows for scalability and integration of additional features to meet user demands and enhance overall application performance.
Last updated January 21, 2025