Part 1: Download and Explore StarterApp
Learning Objectives
By the end of this part, you will:
- Download and set up the PostgreSQL-ready StarterApp
- Understand multi-project solution architecture
- Explore existing MVVM implementation with CommunityToolkit
- Review Entity Framework Core with PostgreSQL configuration
- Understand authentication/authorization patterns
- Apply database migrations to create schema
- Master the MVVM pattern and services layer architecture
Estimated time: 60-90 minutes
1.1: Download StarterApp
The StarterApp is a production-quality MAUI application that demonstrates best practices for mobile development. It has been pre-configured to use PostgreSQL matching the database from your dev-environment tutorial.
- Click Download StarterApp ZIP
- Extract the ZIP file to your desired location (e.g.,
~/Projects/StarterApp) - Skip to Section 1.2
Verify Download
Confirm you have the following structure:
1
2
3
4
5
6
7
StarterApp/
├── StarterApp/ # Main MAUI application project
├── StarterApp.Database/ # Data layer (models, DbContext, services)
├── StarterApp.Migrations/ # Console app for EF Core migrations
├── StarterApp.sln # Solution file
├── README.md # Setup instructions
└── setup/ # Additional setup files
1.2: Open in Development Environment
Using VS Code (Recommended)
- Open VS Code
- File → Open Folder → Navigate to your StarterApp directory
- If prompted “Reopen in Container”, click yes
- Wait for the Dev Container to build (first time takes several minutes)
- Extensions will load automatically
- If not prompted, press F1 → “Dev Containers: Reopen in Container”
Verify Development Environment
Once the container loads, verify your setup:
1
2
3
4
5
6
7
# Check .NET version
dotnet --version
# Should show: 8.0.x or 9.0.x
# Check PostgreSQL connection (from dev-environment tutorial)
docker ps | grep postgres
# Should show a running PostgreSQL container
PostgreSQL Required: Make sure you have completed the dev-environment tutorial and have a PostgreSQL container running. Without it, the application cannot connect to the database.
1.3: Explore Project Structure
Open the solution in VS Code and explore the multi-project architecture:
Solution Organization
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
StarterApp.sln
│
├── StarterApp/ # PROJECT: MAUI Application (UI Layer)
│ ├── Platforms/ # Platform-specific code (Android, iOS, Windows)
│ ├── Properties/ # Launch settings
│ ├── Resources/ # Images, fonts, styles
│ ├── ViewModels/ # Presentation logic
│ │ ├── BaseViewModel.cs # Common ViewModel functionality
│ │ ├── LoginViewModel.cs # Login page logic
│ │ ├── RegisterViewModel.cs # Registration logic
│ │ └── ...
│ ├── Views/ # XAML pages
│ │ ├── LoginPage.xaml # Login UI
│ │ ├── AppShell.xaml # Navigation structure
│ │ └── ...
│ ├── App.xaml # Application resources
│ ├── MauiProgram.cs # Dependency Injection setup
│ └── StarterApp.csproj # Project file
│
├── StarterApp.Database/ # PROJECT: Data Layer (Business Logic + Persistence)
│ ├── Data/
│ │ └── AppDbContext.cs # EF Core DbContext (PostgreSQL config)
│ ├── Models/ # Database entities
│ │ ├── User.cs # User entity
│ │ ├── Role.cs # Role entity
│ │ └── UserRole.cs # Many-to-many relationship
│ ├── Services/ # Business logic
│ │ ├── IAuthenticationService.cs # Interface
│ │ └── AuthenticationService.cs # Implementation
│ ├── appsettings.json # Database connection string
│ └── StarterApp.Database.csproj # Project file
│
└── StarterApp.Migrations/ # PROJECT: Database Migrations
├── Program.cs # Console app entry point
├── Migrations/ # Migration files (generated)
└── StarterApp.Migrations.csproj # Project file
Why Multi-Project?
Separation of Concerns:
- StarterApp: UI and presentation logic only
- StarterApp.Database: All data access and business logic
- StarterApp.Migrations: Database schema management
Benefits:
- Can reuse
StarterApp.Databasein other projects (Web API, console tools) - UI changes don’t affect database code
- Easier to test business logic independently
- Clear boundaries between layers
1.4: Review Key Files
Let’s examine the most important files to understand how the application is structured.
MauiProgram.cs - Dependency Injection Setup
Location: StarterApp/MauiProgram.cs
This file configures the entire application, including dependency injection.
Open the file and find the key registration sections:
1
2
3
4
5
6
7
8
9
10
11
12
13
// DbContext registration with PostgreSQL
builder.Services.AddDbContext<AppDbContext>();
// Service registration
builder.Services.AddSingleton<IAuthenticationService, AuthenticationService>();
// ViewModel registration
builder.Services.AddSingleton<LoginViewModel>();
builder.Services.AddTransient<RegisterViewModel>();
// View registration
builder.Services.AddSingleton<LoginPage>();
builder.Services.AddTransient<RegisterPage>();
Key concepts:
AddSingleton: One instance shared across the app (like a global variable, but managed)AddTransient: New instance created every time it’s requestedAddDbContext: Special registration for Entity Framework DbContext
Why register everything? Dependency Injection lets the framework automatically provide dependencies to your classes. When LoginViewModel needs IAuthenticationService, the DI container provides it automatically.
AppDbContext.cs - Database Configuration
Location: StarterApp.Database/Data/AppDbContext.cs
This is the heart of Entity Framework Core integration.
Open the file and examine:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
// Load appsettings.json from embedded resource
var a = Assembly.GetExecutingAssembly();
using var stream = a.GetManifestResourceStream("StarterApp.Database.appsettings.json");
var config = new ConfigurationBuilder()
.AddJsonStream(stream)
.Build();
// Configure PostgreSQL connection
optionsBuilder.UseNpgsql(
config.GetConnectionString("DevelopmentConnection")
);
}
// Define database tables
public DbSet<Role> Roles { get; set; }
public DbSet<User> Users { get; set; }
public DbSet<UserRole> UserRoles { get; set; }
Key observations:
UseNpgsql: PostgreSQL providerDbSet<T>: Each DbSet becomes a table in the database- Connection string loaded from
appsettings.json
Database Models
Location: StarterApp.Database/Models/
Examine the model files to see how entities are defined:
User.cs:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class User
{
public int Id { get; set; }
[Required]
[EmailAddress]
public string Email { get; set; }
[Required]
public string FirstName { get; set; }
public string LastName { get; set; }
public string PasswordHash { get; set; }
public string PasswordSalt { get; set; }
// Navigation property (relationship)
public List<UserRole> UserRoles { get; set; } = new();
}
Data annotations:
[Required]: Database column cannot be null[EmailAddress]: Validation attribute- Navigation properties:
UserRolesdefines relationship withUserRoletable
BaseViewModel.cs - MVVM Foundation
Location: StarterApp/ViewModels/BaseViewModel.cs
This base class provides common functionality for all ViewModels:
1
2
3
4
5
6
7
8
9
10
11
public abstract class BaseViewModel : ObservableObject
{
[ObservableProperty]
private bool isBusy;
[ObservableProperty]
private string title = string.Empty;
[ObservableProperty]
private string errorMessage = string.Empty;
}
Key features from CommunityToolkit.Mvvm:
ObservableObject: Base class that implementsINotifyPropertyChanged[ObservableProperty]: Attribute that generates full property with change notification
Source Generators: The [ObservableProperty] attribute uses C# source generators to automatically create a public IsBusy property that notifies the UI when changed. You write less boilerplate!
AuthenticationService.cs - Business Logic Example
Location: StarterApp.Database/Services/AuthenticationService.cs
This service demonstrates the service pattern for business logic:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class AuthenticationService : IAuthenticationService
{
private readonly AppDbContext _context;
public AuthenticationService(AppDbContext context)
{
_context = context; // Injected via DI
}
public async Task<bool> LoginAsync(string email, string password)
{
var user = await _context.Users
.FirstOrDefaultAsync(u => u.Email == email);
if (user == null) return false;
// Verify password using BCrypt
return BCrypt.Net.BCrypt.Verify(password, user.PasswordHash);
}
}
Pattern observations:
- Interface-based:
IAuthenticationServicedefines the contract - Dependency Injection:
AppDbContextinjected in constructor - Async/await: All database operations are asynchronous
- BCrypt hashing: Never store plain-text passwords
1.5: Configure and Run
Update Connection String
- Open
StarterApp.Database/appsettings.json - Verify the connection string matches your PostgreSQL setup:
1
2
3
4
5
{
"ConnectionStrings": {
"DevelopmentConnection": "Host=localhost;Username=student_user;Password=password123;Database=starterapp"
}
}
Credentials: These match the dev-environment tutorial. If you used different credentials, update them here.
Create Initial Migration
Navigate to the Migrations project and create the database schema:
1
2
3
4
5
6
7
8
# Navigate to Migrations project
cd StarterApp.Migrations
# Create initial migration (generates schema from models)
dotnet ef migrations add InitialCreate
# Apply migration to database (creates tables)
dotnet ef database update
What just happened?
migrations add: Analyzed your models and generated C# code to create the database schemadatabase update: Executed that code against PostgreSQL to create tables
Verify Database Creation
Use VS Code’s PostgreSQL extension (from dev-environment tutorial):
- Connect to your PostgreSQL database
- Expand the
starterappdatabase - Verify tables exist:
UsersRolesUserRoles__EFMigrationsHistory(tracks which migrations have been applied)
Build and Run
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Navigate to main app project
cd ../StarterApp
# Restore dependencies
dotnet restore
# Build the application
dotnet build
# Run on Windows
dotnet run
# OR run on Android emulator (requires emulator running)
dotnet build -t:Run -f net9.0-android
First run takes time: MAUI applications compile platform-specific code, so the first build can take 2-5 minutes.
Test the Application
If you seeded the database with sample users (check migrations), try logging in with the provided credentials. Otherwise, create a new account using the registration page.
1.6: Understanding MVVM Architecture
Critical Learning Section
This section provides deep understanding of the MVVM pattern and services layer - foundational concepts for the entire tutorial. Take your time here!
What is MVVM?
MVVM (Model-View-ViewModel) is an architectural pattern that separates concerns in UI applications into three distinct layers:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
┌─────────────────────────────────────────┐
│ View (XAML) │
│ - Layout and appearance │
│ - Data binding expressions │
│ - No business logic │
│ - User interaction triggers │
└──────────────┬──────────────────────────┘
│ Bindings (Two-way)
│ {Binding Property}
│ {Binding Command}
┌──────────────▼──────────────────────────┐
│ ViewModel │
│ - Presentation logic │
│ - Properties (data for View) │
│ - Commands (actions from View) │
│ - State management (IsBusy, errors) │
│ - Orchestrates services │
└──────────────┬──────────────────────────┘
│ Method calls
│ Dependency injection
┌──────────────▼──────────────────────────┐
│ Services Layer │
│ - Business logic │
│ - Data access (repositories) │
│ - External APIs │
│ - Authentication │
└──────────────┬──────────────────────────┘
│ EF Core / HTTP
┌──────────────▼──────────────────────────┐
│ Model │
│ - Data structures │
│ - Domain objects │
│ - Database entities │
│ - Validation rules │
└─────────────────────────────────────────┘
Explore MVVM in StarterApp
Let’s trace through a real example from the StarterApp to see MVVM in action.
Layer 1: View - LoginPage.xaml
Location: StarterApp/Views/LoginPage.xaml
Open the file and examine the bindings:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<Entry
Text="{Binding Email}"
Placeholder="Email"
Keyboard="Email" />
<Entry
Text="{Binding Password}"
Placeholder="Password"
IsPassword="True" />
<Button
Command="{Binding LoginCommand}"
Text="Login"
IsEnabled="{Binding IsNotBusy}" />
Key observations:
{Binding Email}: Two-way data binding - changes in UI update ViewModel, changes in ViewModel update UI{Binding LoginCommand}: When button is clicked, execute the command from ViewModel- No C# code-behind: All logic is in the ViewModel, not the View
What is data binding? It’s a mechanism that automatically synchronizes data between the View and ViewModel. When you type in the Entry, the Email property in the ViewModel updates automatically. When the ViewModel changes IsNotBusy, the button’s enabled state updates automatically.
Layer 2: ViewModel - LoginViewModel.cs
Location: StarterApp/ViewModels/LoginViewModel.cs
Open the file and examine the structure:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
public partial class LoginViewModel : BaseViewModel
{
private readonly IAuthenticationService _authService;
public LoginViewModel(IAuthenticationService authService)
{
_authService = authService; // Injected by DI
}
// Properties bound to View
[ObservableProperty]
private string email = string.Empty;
[ObservableProperty]
private string password = string.Empty;
// Command bound to Button
[RelayCommand]
private async Task Login()
{
if (string.IsNullOrWhiteSpace(Email) || string.IsNullOrWhiteSpace(Password))
{
ErrorMessage = "Please enter email and password";
return;
}
IsBusy = true;
ErrorMessage = string.Empty;
try
{
var success = await _authService.LoginAsync(Email, Password);
if (success)
{
// Navigate to main page
await Shell.Current.GoToAsync("///main");
}
else
{
ErrorMessage = "Invalid credentials";
}
}
catch (Exception ex)
{
ErrorMessage = $"Login failed: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
}
Key observations:
[ObservableProperty]: Generates a public property with change notification- Code above generates:
public string Email { get => email; set => SetProperty(ref email, value); }
- Code above generates:
[RelayCommand]: Generates a command that the View can bind to- Code above generates:
public IAsyncRelayCommand LoginCommand { get; }
- Code above generates:
- Presentation logic: Validation, error messages, navigation
- No database knowledge: Calls
_authService, doesn’t know about DbContext - State management:
IsBusyshows loading spinner,ErrorMessageshows errors
Layer 3: Service - AuthenticationService.cs
Location: StarterApp.Database/Services/AuthenticationService.cs
We saw this earlier, but let’s examine it in context:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class AuthenticationService : IAuthenticationService
{
private readonly AppDbContext _context;
public AuthenticationService(AppDbContext context)
{
_context = context;
}
public async Task<bool> LoginAsync(string email, string password)
{
var user = await _context.Users
.FirstOrDefaultAsync(u => u.Email == email);
if (user == null) return false;
return BCrypt.Net.BCrypt.Verify(password, user.PasswordHash);
}
}
Key observations:
- Business logic: How to verify credentials (BCrypt verification)
- Data access: Uses DbContext to query database
- No UI knowledge: Returns a simple
bool, doesn’t know about navigation or error messages - Testable: Can be unit tested by mocking
AppDbContext
Layer 4: Model - User.cs
Location: StarterApp.Database/Models/User.cs
1
2
3
4
5
6
7
8
9
10
11
12
public class User
{
public int Id { get; set; }
[Required]
[EmailAddress]
public string Email { get; set; }
public string PasswordHash { get; set; }
// ... other properties
}
Key observations:
- Pure data: No behavior, just properties
- Maps to database: EF Core creates a table from this
- Validation attributes:
[Required],[EmailAddress]
Tracing a Login Request Through All Layers
Let’s follow what happens when a user clicks the Login button:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
1. USER ACTION
User types "john@example.com" and "password123"
User clicks "Login" button
↓
2. VIEW (LoginPage.xaml)
- Button's Command binding triggers LoginCommand
- Passes control to ViewModel
↓
3. VIEWMODEL (LoginViewModel.cs)
- LoginCommand executes Login() method
- Sets IsBusy = true (shows loading spinner in UI)
- Calls _authService.LoginAsync(Email, Password)
↓
4. SERVICE (AuthenticationService.cs)
- Receives email and password
- Queries database via DbContext
- Verifies password using BCrypt
- Returns true or false
↓
5. MODEL (User entity)
- EF Core queries Users table
- Returns User object (or null)
↓
6. BACK TO VIEWMODEL
- Receives result from service
- If success: Navigate to main page
- If failure: Set ErrorMessage = "Invalid credentials"
- Sets IsBusy = false (hides loading spinner)
↓
7. BACK TO VIEW
- Data binding updates UI automatically
- ErrorMessage displays in red
- Loading spinner disappears
Why This Separation? The Value Proposition
Problem Without MVVM
Imagine all logic in the code-behind (LoginPage.xaml.cs):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ❌ BAD: Everything in code-behind
private async void LoginButton_Clicked(object sender, EventArgs e)
{
var email = EmailEntry.Text;
var password = PasswordEntry.Text;
var user = await _context.Users.FirstOrDefaultAsync(u => u.Email == email);
if (user != null && BCrypt.Verify(password, user.PasswordHash))
{
await Navigation.PushAsync(new MainPage());
}
else
{
await DisplayAlert("Error", "Invalid credentials", "OK");
}
}
Problems:
- UI code mixed with database code (tight coupling)
- Can’t test without creating UI elements
- Can’t reuse logic in different views
- Hard to maintain as app grows
- Changes to UI require changing business logic
Benefits of MVVM + Services
Testability
1
2
3
4
5
6
7
8
9
// Easy to unit test ViewModels
var mockAuthService = new Mock<IAuthenticationService>();
mockAuthService.Setup(x => x.LoginAsync("test@test.com", "pass"))
.ReturnsAsync(true);
var viewModel = new LoginViewModel(mockAuthService.Object);
await viewModel.LoginCommand.ExecuteAsync(null);
Assert.IsTrue(viewModel.IsAuthenticated);
Reusability
AuthenticationServiceused by multiple ViewModels- ViewModels shared across iOS, Android, Windows (same code!)
- Login logic written once, used everywhere
Maintainability
- Change database? Update service implementation only
- Change UI? Update XAML only
- Add feature? Clear place for each concern
- Code reviews easier (each layer reviewed separately)
Flexibility
- Swap implementations (local DB → API)
- A/B test different UIs with same logic
- Support multiple platforms with shared code
The Services Layer - Deep Dive
What is a Service?
A service is a class that encapsulates specific business logic or data access. It implements an interface to enable dependency injection and testing.
Service characteristics:
- Single Responsibility: Each service does one thing (auth, data access, notifications)
- Interface-based: Implements an interface (enables DI and mocking)
- Stateless (usually): Doesn’t hold user-specific state between calls
- Injectable: Registered in DI container, injected into consumers
Example: Authentication Service
Interface (contract):
1
2
3
4
5
6
7
8
public interface IAuthenticationService
{
Task<bool> LoginAsync(string email, string password);
Task<bool> RegisterAsync(string email, string password, string firstName, string lastName);
Task LogoutAsync();
bool IsAuthenticated { get; }
User? CurrentUser { get; }
}
Implementation:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class AuthenticationService : IAuthenticationService
{
private readonly AppDbContext _context;
private User? _currentUser;
public AuthenticationService(AppDbContext context)
{
_context = context; // Dependency injected
}
public bool IsAuthenticated => _currentUser != null;
public User? CurrentUser => _currentUser;
public async Task<bool> LoginAsync(string email, string password)
{
var user = await _context.Users
.FirstOrDefaultAsync(u => u.Email == email);
if (user == null) return false;
if (BCrypt.Net.BCrypt.Verify(password, user.PasswordHash))
{
_currentUser = user;
return true;
}
return false;
}
// ... other methods
}
Registration (MauiProgram.cs):
1
builder.Services.AddSingleton<IAuthenticationService, AuthenticationService>();
Usage in ViewModel:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class LoginViewModel : BaseViewModel
{
private readonly IAuthenticationService _authService;
public LoginViewModel(IAuthenticationService authService)
{
_authService = authService; // Automatically injected
}
[RelayCommand]
private async Task Login()
{
var success = await _authService.LoginAsync(Email, Password);
// Handle result...
}
}
Why Services > Direct Database Access?
❌ BAD: ViewModel accesses DbContext directly
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class LoginViewModel : BaseViewModel
{
private readonly AppDbContext _context;
public LoginViewModel(AppDbContext context)
{
_context = context;
}
[RelayCommand]
private async Task Login()
{
// ViewModel knows about database structure
var user = await _context.Users
.FirstOrDefaultAsync(u => u.Email == Email);
// ViewModel knows about BCrypt
if (user != null && BCrypt.Net.BCrypt.Verify(Password, user.PasswordHash))
{
// Success logic...
}
}
}
Problems:
- ViewModel knows database structure (tight coupling)
- Can’t swap to API without changing ViewModel
- Hard to test (requires real database)
- Logic duplicated if multiple ViewModels need auth
- Violates Single Responsibility Principle
✅ GOOD: ViewModel uses service
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class LoginViewModel : BaseViewModel
{
private readonly IAuthenticationService _authService;
public LoginViewModel(IAuthenticationService authService)
{
_authService = authService;
}
[RelayCommand]
private async Task Login()
{
var success = await _authService.LoginAsync(Email, Password);
if (success)
{
await Shell.Current.GoToAsync("///main");
}
else
{
ErrorMessage = "Invalid credentials";
}
}
}
Benefits:
- ViewModel doesn’t know about database or BCrypt
- Can swap service implementation (API vs local DB)
- Easy to test with mock service
- Login logic centralized and reusable
- Clear separation of concerns
Key Takeaways
MVVM Architecture:
- View: XAML markup, bindings, no logic
- ViewModel: Presentation logic, orchestrates services, manages state
- Model: Data structures, domain objects
- Services: Business logic, data access, reusable
Why This Matters:
- Testability: Each layer can be unit tested independently
- Reusability: Services used by multiple ViewModels, ViewModels shared across platforms
- Maintainability: Changes isolated to specific layers
- Flexibility: Swap implementations without breaking dependent code
Data Flow:
- User interacts with View (clicks button, types text)
- View triggers ViewModel via bindings (Command, Property)
- ViewModel calls Service to perform business logic
- Service accesses data via DbContext or API
- Service returns result to ViewModel
- ViewModel updates properties
- View automatically updates via data binding
Summary and Next Steps
In this part, you:
- Downloaded and set up the PostgreSQL-ready StarterApp
- Explored the multi-project solution architecture
- Reviewed key files: MauiProgram.cs, AppDbContext.cs, models, ViewModels
- Applied database migrations to create schema
- Mastered the MVVM pattern and services layer
- Traced a request through all architectural layers
Teaching Moments Recap
- Multi-project architecture separates concerns (UI, data, migrations)
- CommunityToolkit.Mvvm reduces MVVM boilerplate with source generators (
[ObservableProperty],[RelayCommand]) - Service abstractions (
IAuthenticationService) enable testing and flexibility - Migrations track schema evolution over time
- MVVM pattern provides clear separation between UI, logic, and data
- Services layer abstracts business logic for reusability and testability
- Dependency Injection connects all layers with loose coupling
Next Part
Now that you understand the existing architecture, you’re ready to modify it.
Part 2: Simplify to Note-Taking App →
In Part 2, you’ll:
- Remove authentication complexity
- Create new models:
NoteandCategory - Update DbContext and generate migrations
- Create new ViewModels and Views for note-taking
- Understand schema refactoring patterns
Estimated time for Part 2: 120-150 minutes