Contact Manager with Avalonia and PostgreSQL

Build a desktop CRUD app with Avalonia UI, MVVM, and PostgreSQL.

Introduction

30 Second Summary

On a tous une liste de contacts quelque part. Mais construire sa propre application pour les gerer, connectee a une vraie base de donnees, c'est une autre histoire.

Dans ce projet, tu vas construire un gestionnaire de contacts desktop avec Avalonia UI et PostgreSQL. Tu creeras une interface complete pour afficher, ajouter et supprimer des contacts, le tout connecte a une vraie base de donnees.

What You'll Build

Tu lances l'application, tes contacts se chargent depuis la base, et tu peux en ajouter ou en supprimer en un clic.

A la fin de ce projet, tu auras :

  • Une liste de contacts chargee automatiquement depuis PostgreSQL des que tu lances l'application.
  • Un formulaire d'ajout ou tu entres un nom, un email et un telephone, tu cliques "Ajouter", et le contact apparait dans la liste et dans la base.
  • Un bouton Supprimer sur chaque ligne qui efface le contact de l'ecran et de la base de donnees en un clic.
  • Secret Mission : une barre de recherche qui filtre les contacts en temps reel pendant que tu tapes.

Y a-t-il des prerequis ?

Ce projet suppose que tu connais les bases de C# (classes, proprietes) et les bases de SQL (SELECT, INSERT, DELETE). PostgreSQL doit etre installe et fonctionnel sur ta machine.

Before We Start

Before you start coding, this is your moment to commit to what you're building and why. Thinking through the goal now keeps you oriented as you assemble each piece of the project.

Configure the Avalonia Project and Database

Ton gestionnaire de contacts a besoin de deux fondations pour fonctionner : un projet Avalonia pour l'interface desktop et une base PostgreSQL pour stocker les donnees.

Dans cette etape, tu vas creer le projet dans Visual Studio, installer les packages necessaires, et preparer la base de donnees avec quelques contacts de test.

In this step, get ready to:
  • Creer un projet Avalonia MVVM dans Visual Studio.
  • Installer les packages Npgsql et CommunityToolkit.Mvvm.
  • Creer la base de donnees et la table contacts dans PostgreSQL.
Creer le projet Avalonia MVVM dans Visual Studio

Avalonia utilise le pattern MVVM (Model-View-ViewModel) pour separer l'interface de la logique metier. Le template MVVM genere une structure de projet toute prete avec les dossiers ViewModels/ et Views/.

  • Appuie sur Cmd+Espace (macOS) ou la touche Windows (Windows) pour ouvrir la barre de recherche.
  • Tape Visual Studio et appuie sur Entree pour l'ouvrir.
  • Clique sur File dans la barre de menus.
  • Clique sur New, puis sur Project.
  • Dans la barre de recherche, tape Avalonia.

✔️ Je vois le template Avalonia .NET MVVM App

Le template est pret. Continue avec les instructions ci-dessous pour configurer le projet.

ⓧ Je ne vois aucun template Avalonia

L'extension Avalonia n'est pas encore installee. Installe-la pour obtenir les templates.

  • Ferme la fenetre de creation de projet.
  • Clique sur Extensions dans la barre de menus.
  • Clique sur Manage Extensions.
  • Tape Avalonia dans la barre de recherche.
  • Installe l'extension Avalonia for Visual Studio et redemarre Visual Studio.
  • Apres le redemarrage, reprends depuis File, New, Project et recherche Avalonia a nouveau.

L'extension ne s'installe pas ?

Verifie que tu utilises Visual Studio 2022 version 17.12 ou plus recente. Les anciennes versions ne sont pas compatibles avec l'extension Avalonia actuelle.

Aide-moi a installer l'extension Avalonia dans Visual Studio.

ⓧ Je vois d'autres templates mais pas MVVM

Si tu vois des templates Avalonia mais pas le template MVVM specifiquement, ton extension est peut-etre obsolete.

  • Clique sur Extensions, puis Manage Extensions.
  • Cherche Avalonia et mets a jour l'extension si une mise a jour est disponible.
  • Redemarre Visual Studio et recherche Avalonia dans la creation de projet.
  • Selectionne Avalonia .NET MVVM App dans les resultats. Si plusieurs options apparaissent, choisis celle en C#.
  • Clique sur Next.
  • Entre ContactManager comme nom de projet.
  • Clique sur Next.
  • Selectionne .NET 8 comme framework.
  • Selectionne Desktop comme plateforme cible.
  • Clique sur Create.

Visual Studio genere le projet avec une structure MVVM. Tu devrais voir les dossiers ViewModels/ et Views/ dans l'explorateur de solution.

Qu'est-ce que le pattern MVVM ?

MVVM separe ton application en trois couches. Le Model represente les donnees (un contact). La View est l'interface graphique (les fichiers .axaml). Le ViewModel fait le lien entre les deux avec la logique metier.

Le data binding connecte automatiquement les proprietes du ViewModel aux elements visuels de la View. Quand une propriete change, l'interface se met a jour toute seule.

Installer les packages NuGet

Le projet a besoin de deux packages NuGet supplementaires. Npgsql est le driver qui permet a C# de communiquer avec PostgreSQL. CommunityToolkit.Mvvm simplifie le pattern MVVM avec des attributs comme [ObservableProperty] et [RelayCommand].

  • Ouvre le terminal integre de Visual Studio en cliquant sur View, puis Terminal.
  • Installe Npgsql en executant cette commande :
dotnet add package Npgsql --version 10.0.2

Tu devrais voir un message confirmant que le package a ete ajoute au projet.

  • Installe CommunityToolkit.Mvvm en executant cette commande :
dotnet add package CommunityToolkit.Mvvm --version 8.4.2

Le terminal affiche un message de confirmation. Les deux packages sont maintenant references dans ton fichier ContactManager.csproj.

Le package ne s'installe pas ?

Verifie que le terminal est bien positionne dans le dossier du projet (celui qui contient le fichier .csproj). Si ce n'est pas le cas, navigue vers le bon dossier avec cd ContactManager.

Pourquoi mon package NuGet ne s'installe pas dans mon projet Avalonia ?

Creer la base de donnees et la table contacts

PostgreSQL stocke les donnees dans des bases de donnees. Tu vas creer une base dediee au projet et une table pour stocker les contacts avec leur nom, email et telephone.

  • Connecte-toi a PostgreSQL en executant cette commande dans le terminal :
psql -U postgres

Que fait cette commande ?

La commande psql ouvre le client interactif de PostgreSQL. L'option -U postgres indique de se connecter avec l'utilisateur postgres. Si un mot de passe est demande, entre le mot de passe que tu as defini lors de l'installation de PostgreSQL.

  • Cree la base de donnees en executant cette commande SQL :
CREATE DATABASE contactmanager;

Tu devrais voir CREATE DATABASE comme confirmation.

  • Connecte-toi a la nouvelle base de donnees en executant :
\c contactmanager

Le terminal affiche un message du type You are now connected to database "contactmanager".

  • Cree la table contacts en executant cette commande :
CREATE TABLE contacts (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    email VARCHAR(150),
    phone VARCHAR(20)
);

Que fait cette table ?

  • id SERIAL PRIMARY KEY genere automatiquement un identifiant unique pour chaque contact.
  • name VARCHAR(100) NOT NULL stocke le nom du contact. NOT NULL rend ce champ obligatoire.
  • email et phone sont optionnels pour plus de flexibilite.

Tu devrais voir CREATE TABLE comme confirmation.

  • Insere trois contacts de test en executant cette commande :
INSERT INTO contacts (name, email, phone) VALUES
    ('Alice Dupont', 'alice@example.com', '0601020304'),
    ('Bob Martin', 'bob@example.com', '0611223344'),
    ('Claire Leroy', 'claire@example.com', '0622334455');
  • Verifie que les donnees sont bien la en executant :
SELECT * FROM contacts;

Tu devrais voir un tableau avec trois lignes : Alice Dupont, Bob Martin et Claire Leroy, chacune avec son email et son telephone.

  • Quitte psql en tapant :
\q

La commande CREATE DATABASE echoue ?

Si tu vois une erreur indiquant que la base existe deja, c'est que tu as deja cree contactmanager auparavant. Tu peux la supprimer avec DROP DATABASE contactmanager; puis relancer CREATE DATABASE.

Aide-moi a resoudre un probleme avec ma base PostgreSQL.

Lancer l'application et verifier la configuration

Tout est en place : le projet Avalonia, les packages NuGet, et la base de donnees PostgreSQL. Avant de passer a la suite, verifions que le projet se lance correctement.

A ton avis, qu'est-ce que tu vas voir apparaitre a l'ecran quand tu lanceras l'application pour la premiere fois ?

  • Retourne dans le terminal de Visual Studio.
  • Lance l'application en executant cette commande :
dotnet run

Une fenetre s'ouvre avec le texte "Welcome to Avalonia!". C'est l'ecran par defaut du template MVVM. Tu le remplaceras par l'interface du gestionnaire de contacts dans les prochaines etapes.

La fenetre ne s'ouvre pas ?

Verifie que le terminal est positionne dans le dossier contenant le fichier ContactManager.csproj. Si tu vois des erreurs de compilation, assure-toi que les deux packages NuGet sont bien installes en relancant les commandes dotnet add package.

Mon projet Avalonia ne se lance pas avec dotnet run. Aide-moi.

Ton projet est configure et ta base de donnees est prete. Dans la prochaine etape, tu vas construire l'interface et afficher les premiers contacts a l'ecran.

Display Contacts in the Interface

Your project is set up, your packages are installed, and your database has contacts waiting to be displayed. Now it's time to see something on screen.

You'll start with hardcoded data in the ViewModel to validate that MVVM data binding works before touching the database. This way, if something looks wrong in the UI, you know the problem is in the binding, not the database connection.

In this step, get ready to:
  • Create a Contact model class with properties for each contact field.
  • Populate the ViewModel with hardcoded contacts using an ObservableCollection.
  • Display the contacts in a ListBox with AXAML data binding.
Create the Contact model

Every contact has an ID, a name, an email, and a phone number. A model class gives this data a clear shape that the rest of the app can rely on.

  • In the Solution Explorer on the right side of Visual Studio, right-click the ContactManager project.
  • Select Add, then New Folder.
  • Name the folder Models.
  • Right-click the Models folder, select Add, then Class.
  • Name the file Contact.cs and click Add.
  • Replace the contents of Contact.cs with the following code:
namespace ContactManager.Models;

// Represents a single contact stored in the database
public class Contact
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string Email { get; set; } = string.Empty;
    public string Phone { get; set; } = string.Empty;
}

What does this code do?

This class defines the shape of a contact. Each property maps to a column in your contacts table: Id, Name, Email, and Phone.

The = string.Empty default value prevents null reference warnings. This is a common C# pattern for string properties.

  • Save Contact.cs with Ctrl+S (Windows) or Cmd+S (macOS).
  • Confirm that Contact.cs appears inside the Models folder in Solution Explorer.
Configure the ViewModel with hardcoded contacts

The ViewModel is the bridge between your data and your UI. You'll fill it with three test contacts so the interface has something to display.

  • In Solution Explorer, expand the ViewModels folder and open MainWindowViewModel.cs.
  • Replace the entire contents of the file with the following code:
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using ContactManager.Models;

namespace ContactManager.ViewModels;

// Main ViewModel that holds the contact list
public partial class MainWindowViewModel : ObservableObject
{
    // Observable collection that automatically notifies the UI when items change
    public ObservableCollection<Contact> Contacts { get; } = new();

    public MainWindowViewModel()
    {
        // Hardcoded test data to validate data binding
        Contacts.Add(new Contact { Id = 1, Name = "Alice Dupont", Email = "alice@example.com", Phone = "0601020304" });
        Contacts.Add(new Contact { Id = 2, Name = "Bob Martin", Email = "bob@example.com", Phone = "0611223344" });
        Contacts.Add(new Contact { Id = 3, Name = "Claire Leroy", Email = "claire@example.com", Phone = "0622334455" });
    }
}

What does this code do?

ObservableCollection is a special list that notifies the UI whenever an item is added or removed. A regular List<Contact> would not trigger any UI update.

The class inherits from ObservableObject (from CommunityToolkit.Mvvm), which provides the change-notification plumbing that makes data binding work. The constructor adds three contacts so you can test the UI right away.

  • Save MainWindowViewModel.cs.
  • Build the project to check for compilation errors by pressing Ctrl+Shift+B (Windows) or Cmd+Shift+B (macOS).
  • Check the Output panel at the bottom of Visual Studio. You should see Build succeeded with 0 errors.

Seeing build errors?

  • If you see an error about ObservableObject not being found, make sure the using CommunityToolkit.Mvvm.ComponentModel; line is at the top of the file.
  • If you see an error about Contact not being found, check that Contact.cs is inside the Models folder and uses the namespace ContactManager.Models.
  • Make sure the class is marked partial. CommunityToolkit.Mvvm uses source generators that require this keyword.

Help me fix build errors in my Avalonia MVVM project

Build the interface with a ListBox

The ViewModel has contacts ready to display. Now you need to build the view that renders them on screen using AXAML data binding.

  • In Solution Explorer, expand the Views folder and open MainWindow.axaml.
  • Replace the entire contents of the file with the following AXAML code:
<Window xmlns="https://github.com/avaloniaui"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:vm="using:ContactManager.ViewModels"
        x:Class="ContactManager.Views.MainWindow"
        x:DataType="vm:MainWindowViewModel"
        Title="Gestionnaire de Contacts"
        Width="700" Height="500">

    <Design.DataContext>
        <vm:MainWindowViewModel />
    </Design.DataContext>

    <!-- Main layout panel -->
    <DockPanel Margin="16">

        <!-- Title at the top of the window -->
        <TextBlock DockPanel.Dock="Top"
                   Text="Gestionnaire de Contacts"
                   FontSize="24" FontWeight="Bold"
                   Margin="0,0,0,16" />

        <!-- Contact list bound to the Contacts collection -->
        <ListBox ItemsSource="{Binding Contacts}">
            <ListBox.ItemTemplate>
                <DataTemplate>
                    <StackPanel Orientation="Horizontal" Spacing="16">
                        <TextBlock Text="{Binding Name}" FontWeight="Bold" Width="150" />
                        <TextBlock Text="{Binding Email}" Width="200" />
                        <TextBlock Text="{Binding Phone}" />
                    </StackPanel>
                </DataTemplate>
            </ListBox.ItemTemplate>
        </ListBox>

    </DockPanel>
</Window>

What does this AXAML do?

DockPanel is a layout container that docks child elements to different edges. The title TextBlock is docked to the top, and the ListBox fills the remaining space.

ItemsSource="{Binding Contacts}" connects the ListBox to the Contacts collection in your ViewModel. The DataTemplate defines how each contact renders: a horizontal row showing name, email, and phone.

  • Save MainWindow.axaml.

✔️ Awesome, I've got everything!

Great. Double check you've saved all three files before running the app.

ⓧ I'd like to double check the full code

Here is the complete code for each file at this point.

namespace ContactManager.Models;

public class Contact
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string Email { get; set; } = string.Empty;
    public string Phone { get; set; } = string.Empty;
}
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using ContactManager.Models;

namespace ContactManager.ViewModels;

public partial class MainWindowViewModel : ObservableObject
{
    public ObservableCollection<Contact> Contacts { get; } = new();

    public MainWindowViewModel()
    {
        Contacts.Add(new Contact { Id = 1, Name = "Alice Dupont", Email = "alice@example.com", Phone = "0601020304" });
        Contacts.Add(new Contact { Id = 2, Name = "Bob Martin", Email = "bob@example.com", Phone = "0611223344" });
        Contacts.Add(new Contact { Id = 3, Name = "Claire Leroy", Email = "claire@example.com", Phone = "0622334455" });
    }
}
<Window xmlns="https://github.com/avaloniaui"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:vm="using:ContactManager.ViewModels"
        x:Class="ContactManager.Views.MainWindow"
        x:DataType="vm:MainWindowViewModel"
        Title="Gestionnaire de Contacts"
        Width="700" Height="500">

    <Design.DataContext>
        <vm:MainWindowViewModel />
    </Design.DataContext>

    <DockPanel Margin="16">
        <TextBlock DockPanel.Dock="Top"
                   Text="Gestionnaire de Contacts"
                   FontSize="24" FontWeight="Bold"
                   Margin="0,0,0,16" />
        <ListBox ItemsSource="{Binding Contacts}">
            <ListBox.ItemTemplate>
                <DataTemplate>
                    <StackPanel Orientation="Horizontal" Spacing="16">
                        <TextBlock Text="{Binding Name}" FontWeight="Bold" Width="150" />
                        <TextBlock Text="{Binding Email}" Width="200" />
                        <TextBlock Text="{Binding Phone}" />
                    </StackPanel>
                </DataTemplate>
            </ListBox.ItemTemplate>
        </ListBox>
    </DockPanel>
</Window>
Launch the app and verify the contact list

Everything is wired up. Before you run the app, think about what you expect to see.

You created three hardcoded contacts and bound them to a ListBox. What do you think the window will display when the app launches?

  • Run the application by pressing F5 or clicking the green Start button in Visual Studio.

The window opens with the title "Gestionnaire de Contacts" at the top. Below it, you see three rows: Alice Dupont, Bob Martin, and Claire Leroy, each with their email and phone number displayed side by side.

Window is empty or shows the old greeting?

  • If you still see "Welcome to Avalonia!", make sure you saved MainWindow.axaml before running. The old template content is still loaded if the file was not saved.
  • If the window is blank with no contacts, check that MainWindowViewModel.cs has the three Contacts.Add(...) lines inside the constructor.
  • If you see an error about x:DataType, verify that the xmlns:vm line in MainWindow.axaml matches using:ContactManager.ViewModels exactly.

Help me fix my Avalonia ListBox not showing contacts

Your data binding works. Next up, you'll replace those hardcoded contacts with real data from PostgreSQL.

Connect PostgreSQL and Load Real Contacts

Your app displays contacts, but they are hardcoded. Every time you restart, you see the same three contacts no matter what is in your database.

In this step, you will replace those hardcoded contacts with a real SELECT query against PostgreSQL. When the app starts, it will pull contacts straight from the database.

In this step, get ready to:
  • Create a ContactService that queries PostgreSQL with Npgsql.
  • Wire the service into the ViewModel to load contacts at startup.
Create the data access service

To keep database logic separate from the UI, you will create a dedicated service class. This class holds the connection string and the query method.

  • In the Solution Explorer sidebar, right-click the ContactManager project.
  • Select Add, then New Folder.
  • Name the folder Services.
  • Right-click the new Services folder, select Add, then Class....
  • Name the file ContactService.cs and click Add.
  • Replace all the generated code in ContactService.cs with the class skeleton and connection string below:
using ContactManager.Models;
using Npgsql;

namespace ContactManager.Services;

public class ContactService
{
    // Connection string to reach the local PostgreSQL database
    private readonly string _connectionString =
        "Host=localhost;Username=postgres;Password=your-password-here;Database=contactmanager";
}

What does this code do?

This creates the shell of your service class. The _connectionString field tells Npgsql where to find your PostgreSQL server, which user to log in as, and which database to connect to.

  • In the connection string, replace your-password-here with your actual PostgreSQL password for the postgres user.

Keep your password safe

Hardcoding a password is fine for a local learning project. In a real application, you would store the connection string in an environment variable or a secrets manager.

The service needs a method to actually query the database. This method opens a connection, runs a SELECT query, and builds a list of Contact objects from the results.

  • In ContactService.cs, add the following method inside the ContactService class, below the _connectionString field:
    // Query all contacts from the database
    public async Task<List<Contact>> GetAllContactsAsync()
    {
        var contacts = new List<Contact>();
        await using var dataSource = NpgsqlDataSource.Create(_connectionString);
        await using var command = dataSource.CreateCommand(
            "SELECT id, name, email, phone FROM contacts ORDER BY name");
        await using var reader = await command.ExecuteReaderAsync();

        while (await reader.ReadAsync())
        {
            contacts.Add(new Contact
            {
                Id = reader.GetInt32(0),
                Name = reader.GetString(1),
                Email = reader.IsDBNull(2) ? string.Empty : reader.GetString(2),
                Phone = reader.IsDBNull(3) ? string.Empty : reader.GetString(3)
            });
        }

        return contacts;
    }

What does this method do?

NpgsqlDataSource.Create() creates a data source that manages connections to your PostgreSQL server. The CreateCommand() call prepares a SQL query, and ExecuteReaderAsync() sends it to the database.

The while (await reader.ReadAsync()) loop reads each row one at a time. For each row, a new Contact object is created from the column values. The IsDBNull() checks handle cases where the email or phone column is empty in the database.

  • Save ContactService.cs.
  • Confirm the file appears under the Services folder in the Solution Explorer.

Awesome, I've got everything!

Great. Make sure you have saved ContactService.cs and that you replaced your-password-here with your actual PostgreSQL password.

I'd like to double check the full code

Scroll up and confirm your ContactService.cs has these two parts in order:

  • The class skeleton with the _connectionString field (your password should be filled in).
  • The GetAllContactsAsync() method inside the class body.
Wire the service into the ViewModel

The service can query PostgreSQL, but nothing in the app calls it yet. You need to replace the hardcoded contacts in the ViewModel with a call to GetAllContactsAsync().

  • Open ViewModels/MainWindowViewModel.cs in the Solution Explorer.
  • Select all the existing code and replace it with the updated class structure below:
using System.Collections.ObjectModel;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ContactManager.Models;
using ContactManager.Services;

namespace ContactManager.ViewModels;

public partial class MainWindowViewModel : ObservableObject
{
    // Service that handles all database queries
    private readonly ContactService _contactService = new();
    private List<Contact> _allContacts = new();

    public ObservableCollection<Contact> Contacts { get; } = new();

    public MainWindowViewModel()
    {
        // Load contacts from the database when the app starts
        LoadContactsCommand.Execute(null);
    }
}

What changed from the previous version?

The three hardcoded Contacts.Add() calls are gone. Instead, a _contactService field holds a reference to the data access layer you just built.

The constructor now calls LoadContactsCommand.Execute(null) to trigger loading contacts from PostgreSQL as soon as the window opens. You will add that command next.

  • Add the following method inside the MainWindowViewModel class, below the constructor, before the closing }:
    // Fetch contacts from PostgreSQL and refresh the UI collection
    [RelayCommand]
    private async Task LoadContactsAsync()
    {
        var contacts = await _contactService.GetAllContactsAsync();
        _allContacts = contacts;
        Contacts.Clear();
        foreach (var contact in contacts)
        {
            Contacts.Add(contact);
        }
    }

What does this command do?

The [RelayCommand] attribute from CommunityToolkit.Mvvm auto-generates a LoadContactsCommand property that the constructor can call. The method fetches all contacts from the database, clears the current UI list, and adds each contact back into the ObservableCollection.

Because Contacts is an ObservableCollection, the ListBox in the UI updates automatically whenever items are added or removed. No extra wiring needed.

  • Save MainWindowViewModel.cs.

Awesome, I've got everything!

Make sure you have saved the file before running the app.

I'd like to double check the full code

Scroll up and confirm your MainWindowViewModel.cs has these two parts in order:

  • The class structure with _contactService, _allContacts, the Contacts property, and the constructor calling LoadContactsCommand.Execute(null).
  • The LoadContactsAsync() method with the [RelayCommand] attribute, placed inside the class before the closing }.

Before you run the app, think about what you expect to see. The hardcoded contacts are gone. The constructor now calls a method that queries PostgreSQL. Do you think the same three names will appear, or different ones?

  • Run the application by pressing F5 or clicking the Run button in the toolbar.

The window displays the same three names (Alice Dupont, Bob Martin, Claire Leroy), but now they are loaded from your PostgreSQL database, not hardcoded. The contacts appear sorted alphabetically because of the ORDER BY name clause in your query.

App crashes on startup?

  • Check that PostgreSQL is running on your machine. If the server is not started, the app cannot connect.
  • Verify the password in your _connectionString matches the password you set for the postgres user.
  • Make sure the contactmanager database and the contacts table exist. You can verify by opening psql and running \dt inside the contactmanager database.
  • Still stuck? Help me debug a PostgreSQL connection error in my Avalonia app.

Your app is now connected to a real database. Next up, you will add a form that lets you create new contacts and insert them into PostgreSQL.

Add Contacts via a Form

Your app now pulls real contacts from PostgreSQL and displays them in a list. That is a solid start, but the app is read-only.

In this step, you will add a form that lets you type a name, email, and phone number, then click a button to insert that contact into the database. The button will also disable itself automatically when the name field is empty.

In this step, get ready to:
  • Add observable properties and an add command to the ViewModel.
  • Write a parameterized INSERT query in the data service.
  • Build the form UI in AXAML with two-way data binding.
Add form fields and the add command in the ViewModel

The ViewModel needs three new properties to hold the form input, plus a command that sends those values to the database. CommunityToolkit.Mvvm generates the boilerplate for you through attributes.

  • In ViewModels/MainWindowViewModel.cs, find the existing using statements at the top of the file.
  • Add this line below using System.Collections.ObjectModel;:
using System.Linq;
  • Now find the line private List<Contact> _allContacts = new();. Add these observable properties directly below it:
// Form input fields — CommunityToolkit generates public properties automatically
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(AddContactCommand))]
private string _newName = string.Empty;

[ObservableProperty]
private string _newEmail = string.Empty;

[ObservableProperty]
private string _newPhone = string.Empty;

What do these attributes do?

[ObservableProperty] tells CommunityToolkit.Mvvm to generate a public property (e.g. NewName from _newName) that automatically notifies the UI when its value changes.

[NotifyCanExecuteChangedFor(nameof(AddContactCommand))] means every time NewName changes, the button bound to AddContactCommand re-evaluates whether it should be enabled or disabled.

  • Scroll down past the LoadContactsAsync method. Add the add command and its guard method below it:
// Command that inserts a new contact, clears the form, then refreshes the list
[RelayCommand(CanExecute = nameof(CanAddContact))]
private async Task AddContactAsync()
{
    await _contactService.AddContactAsync(NewName, NewEmail, NewPhone);
    NewName = string.Empty;
    NewEmail = string.Empty;
    NewPhone = string.Empty;
    await LoadContactsAsync();
}

// Guard — only allow adding when the name field is not empty
private bool CanAddContact() => !string.IsNullOrWhiteSpace(NewName);

What does this code do?

[RelayCommand(CanExecute = nameof(CanAddContact))] generates a command property called AddContactCommand. The button bound to it will only be clickable when CanAddContact() returns true.

After the insert, the method resets all three form fields to empty strings and reloads the contact list so the new entry shows up immediately.

  • Save MainWindowViewModel.cs.

Seeing red squiggles under AddContactAsync?

Your IDE may show an error on the _contactService.AddContactAsync call. That is expected because the service method does not exist yet. You will add it in the next substep.

If you see errors on the attributes themselves, make sure your using CommunityToolkit.Mvvm.ComponentModel; and using CommunityToolkit.Mvvm.Input; imports are present at the top of the file.

help me fix attribute errors in my MainWindowViewModel

Implement the insert method in the service

The ViewModel calls AddContactAsync on the service, but that method does not exist yet. Time to write the SQL insert.

  • In Services/ContactService.cs, scroll to the end of the GetAllContactsAsync method. Add this new method below it, inside the class:
// Insert a new contact using positional parameters ($1, $2, $3)
public async Task AddContactAsync(string name, string email, string phone)
{
    await using var dataSource = NpgsqlDataSource.Create(_connectionString);
    await using var command = dataSource.CreateCommand(
        "INSERT INTO contacts (name, email, phone) VALUES ($1, $2, $3)");
    command.Parameters.AddWithValue(name);
    command.Parameters.AddWithValue(email);
    command.Parameters.AddWithValue(phone);
    await command.ExecuteNonQueryAsync();
}

What does this code do?

The query uses positional parameters ($1, $2, $3) instead of pasting user input directly into the SQL string. Npgsql sends the values separately to PostgreSQL, which prevents SQL injection.

AddWithValue adds each parameter in order, matching $1 to name, $2 to email, and $3 to phone. ExecuteNonQueryAsync() runs the insert without returning any rows.

  • Save ContactService.cs.
  • Verify the project compiles by running this in the terminal:
dotnet build

You should see Build succeeded with 0 errors. If not, check the troubleshooting callout below.

Build failed?

Check that AddContactAsync in ContactService.cs has exactly the signature public async Task AddContactAsync(string name, string email, string phone). The ViewModel calls it with three string arguments.

Make sure the CanAddContact method is inside the class but outside any other method. It should sit right below AddContactAsync.

help me fix my build errors in ContactManager

Add the form in the AXAML view

The backend logic is ready. Now you need three text fields and an "Ajouter" button in the window so the user can actually type in a contact.

  • In Views/MainWindow.axaml, find the closing /> of the title TextBlock (the line with Margin="0,0,0,16").
  • Add this form panel directly below it, above the <ListBox> line:
<!-- Form to add a new contact -->
<StackPanel DockPanel.Dock="Top" Spacing="8" Margin="0,0,0,16">
    <StackPanel Orientation="Horizontal" Spacing="8">
        <TextBox Watermark="Nom"
                 Text="{Binding NewName, Mode=TwoWay}"
                 Width="180" />
        <TextBox Watermark="Email"
                 Text="{Binding NewEmail, Mode=TwoWay}"
                 Width="200" />
        <TextBox Watermark="Telephone"
                 Text="{Binding NewPhone, Mode=TwoWay}"
                 Width="140" />
        <Button Content="Ajouter"
                Command="{Binding AddContactCommand}" />
    </StackPanel>
</StackPanel>

What does this AXAML do?

Each TextBox is bound to a ViewModel property with Mode=TwoWay. That means typing in the field updates the ViewModel property, and clearing the property from code empties the field.

The Watermark attribute shows placeholder text ("Nom", "Email", "Telephone") that disappears when the user starts typing. The Button is bound to AddContactCommand, which disables the button automatically when NewName is empty.

  • Save MainWindow.axaml.

Before you run the app, predict: what will happen if you leave the name field empty and try to click the "Ajouter" button?

  • Run the app with F5 or by running this in the terminal:
dotnet run

The window opens with the contact list and a row of form fields above it. The "Ajouter" button is grayed out because the name field is empty.

  • Type a name in the "Nom" field. The "Ajouter" button becomes clickable.
  • Fill in an email and phone number, then click Ajouter.

The new contact appears in the list and the form fields clear themselves. That is the two-way binding and list reload in action.

  • Verify the contact is persisted in the database by opening psql and running:
SELECT * FROM contacts;

You should see the three original contacts plus the one you just added.

Contact not showing up?

Make sure the connection string password in ContactService.cs matches your actual PostgreSQL password. A wrong password will cause a silent failure.

Check that the AXAML bindings use Mode=TwoWay. Without it, the text you type never reaches the ViewModel properties.

help me debug why my contact form is not saving to PostgreSQL

✔️ Awesome, I've got everything!

Great. Double check you have saved both ContactService.cs, MainWindowViewModel.cs, and MainWindow.axaml.

ⓧ I'd like to double check the full code

Scroll up and confirm each code block you added is present and in the right position. Here are the three files for reference.

Services/ContactService.cs:

using ContactManager.Models;
using Npgsql;

namespace ContactManager.Services;

public class ContactService
{
    private readonly string _connectionString =
        "Host=localhost;Username=postgres;Password=your-password-here;Database=contactmanager";

    public async Task<List<Contact>> GetAllContactsAsync()
    {
        var contacts = new List<Contact>();
        await using var dataSource = NpgsqlDataSource.Create(_connectionString);
        await using var command = dataSource.CreateCommand(
            "SELECT id, name, email, phone FROM contacts ORDER BY name");
        await using var reader = await command.ExecuteReaderAsync();

        while (await reader.ReadAsync())
        {
            contacts.Add(new Contact
            {
                Id = reader.GetInt32(0),
                Name = reader.GetString(1),
                Email = reader.IsDBNull(2) ? string.Empty : reader.GetString(2),
                Phone = reader.IsDBNull(3) ? string.Empty : reader.GetString(3)
            });
        }

        return contacts;
    }
    public async Task AddContactAsync(string name, string email, string phone)
    {
        await using var dataSource = NpgsqlDataSource.Create(_connectionString);
        await using var command = dataSource.CreateCommand(
            "INSERT INTO contacts (name, email, phone) VALUES ($1, $2, $3)");
        command.Parameters.AddWithValue(name);
        command.Parameters.AddWithValue(email);
        command.Parameters.AddWithValue(phone);
        await command.ExecuteNonQueryAsync();
    }
}

ViewModels/MainWindowViewModel.cs:

using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ContactManager.Models;
using ContactManager.Services;

namespace ContactManager.ViewModels;

public partial class MainWindowViewModel : ObservableObject
{
    private readonly ContactService _contactService = new();
    private List<Contact> _allContacts = new();
    [ObservableProperty]
    [NotifyCanExecuteChangedFor(nameof(AddContactCommand))]
    private string _newName = string.Empty;

    [ObservableProperty]
    private string _newEmail = string.Empty;

    [ObservableProperty]
    private string _newPhone = string.Empty;

    public ObservableCollection<Contact> Contacts { get; } = new();

    public MainWindowViewModel()
    {
        LoadContactsCommand.Execute(null);
    }
    [RelayCommand]
    private async Task LoadContactsAsync()
    {
        var contacts = await _contactService.GetAllContactsAsync();
        _allContacts = contacts;
        Contacts.Clear();
        foreach (var contact in contacts)
        {
            Contacts.Add(contact);
        }
    }

    [RelayCommand(CanExecute = nameof(CanAddContact))]
    private async Task AddContactAsync()
    {
        await _contactService.AddContactAsync(NewName, NewEmail, NewPhone);
        NewName = string.Empty;
        NewEmail = string.Empty;
        NewPhone = string.Empty;
        await LoadContactsAsync();
    }

    private bool CanAddContact() => !string.IsNullOrWhiteSpace(NewName);
}

Views/MainWindow.axaml:

<Window xmlns="https://github.com/avaloniaui"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:vm="using:ContactManager.ViewModels"
        x:Class="ContactManager.Views.MainWindow"
        x:DataType="vm:MainWindowViewModel"
        Title="Gestionnaire de Contacts"
        Width="700" Height="500">

    <Design.DataContext>
        <vm:MainWindowViewModel />
    </Design.DataContext>

    <DockPanel Margin="16">
        <TextBlock DockPanel.Dock="Top"
                   Text="Gestionnaire de Contacts"
                   FontSize="24" FontWeight="Bold"
                   Margin="0,0,0,16" />

        <StackPanel DockPanel.Dock="Top" Spacing="8" Margin="0,0,0,16">
            <StackPanel Orientation="Horizontal" Spacing="8">
                <TextBox Watermark="Nom"
                         Text="{Binding NewName, Mode=TwoWay}"
                         Width="180" />
                <TextBox Watermark="Email"
                         Text="{Binding NewEmail, Mode=TwoWay}"
                         Width="200" />
                <TextBox Watermark="Telephone"
                         Text="{Binding NewPhone, Mode=TwoWay}"
                         Width="140" />
                <Button Content="Ajouter"
                        Command="{Binding AddContactCommand}" />
            </StackPanel>
        </StackPanel>

        <ListBox ItemsSource="{Binding Contacts}">
            <ListBox.ItemTemplate>
                <DataTemplate>
                    <StackPanel Orientation="Horizontal" Spacing="16">
                        <TextBlock Text="{Binding Name}" FontWeight="Bold" Width="150" />
                        <TextBlock Text="{Binding Email}" Width="200" />
                        <TextBlock Text="{Binding Phone}" />
                    </StackPanel>
                </DataTemplate>
            </ListBox.ItemTemplate>
        </ListBox>

    </DockPanel>
</Window>

Your contact manager can now read and create contacts. But what if you add someone by mistake? In the next step, you will add a delete button on each contact so you can remove entries from both the list and the database.

Delete Contacts

Your app can load contacts from PostgreSQL and add new ones through the form. But what happens when a contact is outdated or was entered by mistake? Right now, you are stuck with it.

In this step, you will add a "Supprimer" button next to each contact. Clicking it will execute a parameterized DELETE query against the database and refresh the list automatically.

In this step, get ready to:
  • Add a DeleteContactAsync method to the data service with a parameterized SQL query.
  • Add a DeleteContact command to the ViewModel.
  • Wire up a "Supprimer" button in the AXAML template to trigger deletion.
Implement deletion in the service and ViewModel

The deletion feature needs two pieces of backend logic: a method in ContactService that runs the SQL query, and a command in the ViewModel that calls it and refreshes the list.

  • In Services/ContactService.cs, scroll to the bottom of the class, right after the closing } of AddContactAsync.
  • Add the following method to handle contact deletion:
    public async Task DeleteContactAsync(int id)
    {
        await using var dataSource = NpgsqlDataSource.Create(_connectionString);
        await using var command = dataSource.CreateCommand(
            "DELETE FROM contacts WHERE id = $1");
        command.Parameters.AddWithValue(id);
        await command.ExecuteNonQueryAsync();
    }

What does this code do?

This method follows the same pattern as AddContactAsync, but with a DELETE query instead of an INSERT.

  • The query DELETE FROM contacts WHERE id = $1 uses a positional parameter ($1) to target exactly one row by its primary key.
  • Using a parameterized query prevents SQL injection. The database engine handles the value safely instead of concatenating raw input into the query string.
  • Save ContactService.cs.

✔️ Awesome, I've got everything!

Great. Make sure you have saved ContactService.cs before moving on to the ViewModel.

ⓧ I'd like to double check the full code

Your ContactService.cs file should now contain three methods in this order:

  • GetAllContactsAsync (from Step 3).
  • AddContactAsync (from Step 4).
  • DeleteContactAsync (the method you just added).

Scroll up and confirm each one is present, in order.

Now the service knows how to delete a contact. The ViewModel needs a command that calls this method and refreshes the list afterward.

  • In ViewModels/MainWindowViewModel.cs, scroll to the bottom of the class, right after the CanAddContact method.
  • Add the following command method:
    [RelayCommand]
    private async Task DeleteContact(Contact contact)
    {
        await _contactService.DeleteContactAsync(contact.Id);
        await LoadContactsAsync();
    }

What does this code do?

  • [RelayCommand] generates a DeleteContactCommand property that the AXAML button can bind to.
  • The method accepts a Contact object as a parameter. This lets the button pass the specific contact to delete via CommandParameter.
  • After the deletion query runs, LoadContactsAsync() reloads the full list from PostgreSQL so the UI stays in sync with the database.
  • Save MainWindowViewModel.cs.

✔️ Awesome, I've got everything!

Great. Make sure you have saved MainWindowViewModel.cs before continuing.

ⓧ I'd like to double check the full code

Your MainWindowViewModel.cs should contain these members in order:

  • The _contactService and _allContacts fields.
  • The three [ObservableProperty] fields: _newName, _newEmail, _newPhone.
  • The Contacts ObservableCollection and constructor.
  • LoadContactsAsync, AddContactAsync, CanAddContact, and the new DeleteContact method.

Scroll up and confirm each one is present, in order.

  • Verify that both files compile by running this command in the Visual Studio terminal:
dotnet build
  • You should see Build succeeded in the output with 0 errors.

Build failed?

  • Check that DeleteContactAsync is inside the ContactService class braces, not outside them.
  • Check that DeleteContact in the ViewModel accepts Contact contact as a parameter, not int id. The command receives the full object from the UI.
  • Make sure both files are saved before building.

Still stuck? Ask for help.

Add the delete button in the template

The backend logic is ready. Now you need a button in each contact row that triggers the DeleteContactCommand.

  • In Views/MainWindow.axaml, scroll down to the ListBox.ItemTemplate section.
  • Find this code inside the DataTemplate:
<DataTemplate>
    <StackPanel Orientation="Horizontal" Spacing="16">
        <TextBlock Text="{Binding Name}" FontWeight="Bold" Width="150" />
        <TextBlock Text="{Binding Email}" Width="200" />
        <TextBlock Text="{Binding Phone}" />
    </StackPanel>
</DataTemplate>
  • Replace the entire DataTemplate block (from <DataTemplate> to </DataTemplate>) with the following:
<DataTemplate>
    <DockPanel Margin="4">
        <Button DockPanel.Dock="Right"
                Content="Supprimer"
                Command="{Binding $parent[ListBox].((vm:MainWindowViewModel)DataContext).DeleteContactCommand}"
                CommandParameter="{Binding}"
                Margin="8,0,0,0" />
        <StackPanel Orientation="Horizontal" Spacing="16">
            <TextBlock Text="{Binding Name}" FontWeight="Bold" Width="150" />
            <TextBlock Text="{Binding Email}" Width="200" />
            <TextBlock Text="{Binding Phone}" />
        </StackPanel>
    </DockPanel>
</DataTemplate>

What changed and why?

  • The StackPanel is now wrapped inside a DockPanel. This lets you dock the delete button to the right side of the row while the contact info fills the remaining space.
  • The binding $parent[ListBox].((vm:MainWindowViewModel)DataContext).DeleteContactCommand is an Avalonia-specific syntax. Inside a DataTemplate, the data context is the individual Contact object, not the ViewModel. This binding walks up the visual tree to the ListBox, casts its DataContext to MainWindowViewModel, and accesses DeleteContactCommand.
  • CommandParameter="{Binding}" passes the current Contact object to the command. That is the contact parameter your DeleteContact method receives.
  • Save MainWindow.axaml.

✔️ Awesome, I've got everything!

Great. Make sure MainWindow.axaml is saved before running the app.

ⓧ I'd like to double check the full code

Your MainWindow.axaml should contain these sections top to bottom inside the root DockPanel:

  • A TextBlock title docked to the top.
  • A StackPanel with the form inputs (Nom, Email, Telephone) and the "Ajouter" button.
  • A ListBox bound to Contacts, with a DataTemplate containing a DockPanel with the "Supprimer" button docked right and the contact info in a horizontal StackPanel.

Scroll up and confirm the updated DataTemplate matches the code you just pasted.

Before you run the app, what do you think will happen when you click "Supprimer" next to a contact?

  • Run the application by pressing F5 or by running this command in the terminal:
dotnet run
  • Click the Supprimer button next to any contact in the list.

The contact disappears from the list immediately. The app deleted it from PostgreSQL and reloaded the list.

  • Confirm the deletion is permanent by switching to psql or pgAdmin and running this query:
SELECT * FROM contacts;

The deleted contact no longer appears in the query results. The app and the database are in sync.

Clicking Supprimer does nothing?

  • Double check the Command binding on the button. The full path $parent[ListBox].((vm:MainWindowViewModel)DataContext).DeleteContactCommand must match exactly, including the parentheses around the cast.
  • Verify that CommandParameter="{Binding}" is present on the button. Without it, the command receives null instead of the contact.
  • Make sure the xmlns:vm namespace declaration at the top of your AXAML file is using:ContactManager.ViewModels. A mismatch causes the cast in the binding to fail silently.

Still stuck? Ask for help.

Your contact manager now supports the full create-read-delete cycle. Every contact in the list can be added through the form or removed with a single click. Next up, you will take on the Secret Mission and make the app even more usable.

Secret mission

Ajouter une recherche en temps reel

Ton app affiche tous les contacts d'un coup. Avec 5 contacts, ca va. Avec 500, bonne chance pour trouver celui que tu cherches. Ajoute une barre de recherche qui filtre les contacts en temps reel pendant que tu tapes.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The ContactManager project folder with all C# source files and NuGet packages.
  • The PostgreSQL contactmanager database with the contacts table.
  • The Avalonia extension installed in Visual Studio.

Keep everything running

No action needed. Choose this if you are still actively building or want to extend the project further.

  • Your ContactManager project stays on disk, ready to open in Visual Studio.
  • The contactmanager database stays in PostgreSQL with all your contacts.
  • The Avalonia extension remains installed in Visual Studio for future Avalonia projects.

Pause - I'll come back to this later

Close Visual Studio to free up memory. Your project files and database stay on disk for when you return.

  • Close Visual Studio if it is still open.
  • PostgreSQL continues running as a background service. It uses minimal resources when idle.
  • To resume later, reopen the ContactManager.sln file in Visual Studio.

Delete - I don't want to use this again

Remove all project resources and start fresh. This deletes your project files and the database permanently.

  • Close Visual Studio if it is still open.

Delete the project folder. Find the ContactManager folder where you saved it when creating the project, then delete it.

  • On macOS/Linux, open a terminal and run:
rm -rf ~/source/repos/ContactManager
  • On Windows, open a command prompt and run:
rmdir /s /q "%USERPROFILE%\source\repos\ContactManager"

Saved somewhere else?

If you chose a different location when creating the project, adjust the path to match where your ContactManager folder actually lives.

Drop the PostgreSQL database. Open a terminal and run:

psql -U postgres -c "DROP DATABASE contactmanager;"

What does this command do?

This connects to PostgreSQL as the postgres user and permanently deletes the contactmanager database along with the contacts table and all its data.

Remove the Avalonia extension from Visual Studio.

  • Reopen Visual Studio.
  • Click Extensions in the top menu bar, then select Manage Extensions.
  • Select the Installed section in the left panel.
  • Find the Avalonia extension, select it, and click Uninstall.
  • Restart Visual Studio to complete the removal.

Nice Work!

Nice Work!

Bravo ! Tu viens de construire un gestionnaire de contacts desktop complet avec Avalonia UI et PostgreSQL.

Tu as appris a :

  • Construire une application desktop avec le pattern MVVM, en separant la vue en AXAML de la logique metier dans un ViewModel.
  • Connecter Npgsql a PostgreSQL et ecrire des requetes SQL parametrees (SELECT, INSERT, DELETE) pour gerer les contacts en toute securite.
  • Utiliser CommunityToolkit.Mvvm avec [ObservableProperty], [RelayCommand] et [NotifyCanExecuteChangedFor] pour un data binding reactif qui synchronise l'interface avec les donnees.
  • Secret Mission : ajouter une barre de recherche en temps reel qui filtre les contacts dans le ViewModel avec LINQ et StringComparison.OrdinalIgnoreCase.

Pret a tester tes connaissances ?