The first button in a WPF window is easy to implement in code-behind. Adding another view, shared services, save state and multiple features makes ownership harder to see. Prism brings MVVM, dependency resolution, commands and region navigation into one application structure. It does not remove business rules; it gives them somewhere other than a growing collection of window events.
For this article I built a small standalone notes application using .NET 10 WPF and Prism.DryIoc 9.0.537. It is separate from this site’s Angular/.NET API. I am not claiming that every archived desktop project used Prism; the example is an explicit introduction rather than an invented project history.
Install a container package and give Prism ownership of startup
dotnet new wpf -n PrismNotes --framework net10.0
cd PrismNotes
dotnet add package Prism.DryIoc --version 9.0.537
Running WPF requires Windows. Selecting DryIoc selects the dependency container; installing both Unity and DryIoc is unnecessary here. Prism 9 uses Community/Commercial licensing. Check the terms for the actual version and intended use instead of assuming that an older release’s license automatically applies.
<prism:PrismApplication x:Class="PrismNotes.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:prism="http://prismlibrary.com/">
<Application.Resources />
</prism:PrismApplication>
App.xaml now uses PrismApplication instead of Application. Remove StartupUri because CreateShell owns construction of the main window. Keeping two startup mechanisms can create two windows. The x:Class value must match the C# namespace and class, and the XAML and code-behind must agree about their base type.
using System.Windows;
using Prism.DryIoc;
using Prism.Ioc;
using PrismNotes.Services;
using PrismNotes.ViewModels;
using PrismNotes.Views;
namespace PrismNotes;
public partial class App : PrismApplication
{
protected override Window CreateShell() => Container.Resolve<MainWindow>();
protected override void RegisterTypes(IContainerRegistry registry)
{
registry.RegisterSingleton<INoteStore, MemoryNoteStore>();
registry.RegisterForNavigation<NotesView, NotesViewModel>("Notes");
}
}
Resolve is intentional at the composition root. A ViewModel instead receives its dependencies through constructor arguments, rather than locating them repeatedly through the container. RegisterSingleton gives INoteStore one shared application instance; NotesView and NotesViewModel are registered as the Notes navigation target. Nothing here connects a control directly to a database.
Let the shell define a place for a feature
<Window x:Class="PrismNotes.Views.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:prism="http://prismlibrary.com/"
prism:ViewModelLocator.AutoWireViewModel="True"
Title="Prism Notes" Width="640" Height="420">
<DockPanel Margin="24">
<Button DockPanel.Dock="Top" Content="Notlar / Notes" Margin="0,0,0,16"
Command="{Binding OpenNotesCommand}" />
<ContentControl prism:RegionManager.RegionName="MainRegion" />
</DockPanel>
</Window>
MainRegion names the ContentControl area where navigation places a view. A region is a presentation slot inside the shell, not another Window. The Notes button requests a feature in that slot. The shell does not need to construct the user control manually or reach into another view’s controls.
AutoWireViewModel explicitly enables ViewModelLocator. The example pairs PrismNotes.Views.MainWindow with PrismNotes.ViewModels.MainWindowViewModel through the naming convention. Namespaces matter as well as folder names. When naming differs, register an explicit mapping; assigning a freshly constructed ViewModel to DataContext may bypass dependency resolution.
Define a store contract and a small in-memory implementation
INoteStore.cs
namespace PrismNotes.Services;
public interface INoteStore
{
IReadOnlyList<string> Read();
void Add(string title);
}
MemoryNoteStore.cs
namespace PrismNotes.Services;
public sealed class MemoryNoteStore : INoteStore
{
private readonly List<string> notes = [];
public IReadOnlyList<string> Read() => notes.ToArray();
public void Add(string title)
{
if (string.IsNullOrWhiteSpace(title)) throw new ArgumentException("A title is required.");
notes.Add(title.Trim());
}
}
These classes live in separate files under Services. Replacing the memory implementation with an API adapter preserves the ViewModel’s INoteStore dependency; the memory implementation keeps the teaching example independent.
Connect property notifications with command availability
using System.Collections.ObjectModel;
using Prism.Commands;
using Prism.Mvvm;
using PrismNotes.Services;
namespace PrismNotes.ViewModels;
public sealed class NotesViewModel : BindableBase
{
private string title = "";
public string Title { get => title; set => SetProperty(ref title, value); }
public ObservableCollection<string> Notes { get; }
public DelegateCommand AddCommand { get; }
public NotesViewModel(INoteStore store)
{
Notes = new ObservableCollection<string>(store.Read());
AddCommand = new DelegateCommand(() =>
{
var note = Title.Trim();
store.Add(note);
Notes.Add(note);
Title = "";
}, () => !string.IsNullOrWhiteSpace(Title)).ObservesProperty(() => Title);
}
}
SetProperty raises PropertyChanged when Title changes. ObservableCollection reports additions to the visible notes list. AddCommand’s second delegate describes its availability, and ObservesProperty causes that condition to be reevaluated when Title changes. A condition calculated only once would not keep the button in sync with typing.
The command trims the title, writes it to the store, updates the list and clears the field. Service-side validation still matters: disabling a button is not a complete business rule. MemoryNoteStore also rejects blank text. It stores data only in memory, so application shutdown loses the notes. Singleton lifetime does not make a service durable storage.
<UserControl x:Class="PrismNotes.Views.NotesView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:prism="http://prismlibrary.com/"
prism:ViewModelLocator.AutoWireViewModel="True">
<StackPanel>
<TextBlock Text="Yeni not / New note" />
<TextBox Margin="0,8" Text="{Binding Title, UpdateSourceTrigger=PropertyChanged}" />
<Button Content="Ekle / Add" Command="{Binding AddCommand}" />
<ItemsControl Margin="0,16,0,0" ItemsSource="{Binding Notes}" />
</StackPanel>
</UserControl>
UpdateSourceTrigger=PropertyChanged sends each edit to the ViewModel immediately, allowing Add to become available before the field loses focus. Command binding carries behavior while XAML describes presentation. The small InitializeComponent code-behind files are normal; MVVM does not require deleting their existence.
Keep navigation target and region names consistent
using Prism.Commands;
using Prism.Mvvm;
using Prism.Navigation.Regions;
namespace PrismNotes.ViewModels;
public sealed class MainWindowViewModel : BindableBase
{
public DelegateCommand OpenNotesCommand { get; }
public MainWindowViewModel(IRegionManager regions)
{
OpenNotesCommand = new DelegateCommand(() => regions.RequestNavigate("MainRegion", "Notes"));
}
}
The command requests Notes in MainRegion. Prism 9 region types use the Prism.Navigation.Regions namespace, so copying a Prism.Regions import from an older example requires a version check. The target must match RegisterForNavigation, and the region must match the attached property in the shell.
A larger workflow can pass record identifiers through NavigationParameters and let the destination service load the data. Passing a whole mutable ViewModel is a different and usually stronger coupling. Unsaved edits and view reuse require the relevant navigation contracts. They are not implemented in this minimal notes screen and should not be inferred merely from the presence of RequestNavigate.
Move registration into a module when there is a real boundary
using Prism.Ioc;
using Prism.Modularity;
using PrismNotes.Views;
using PrismNotes.ViewModels;
public sealed class NotesModule : IModule
{
public void RegisterTypes(IContainerRegistry registry) =>
registry.RegisterForNavigation<NotesView, NotesViewModel>("Notes");
public void OnInitialized(IContainerProvider provider) { }
}
// In App, when moving the Notes registration into the module:
// protected override void ConfigureModuleCatalog(IModuleCatalog catalog)
// => catalog.AddModule<NotesModule>();
An IModule becomes useful when the feature gains an assembly and dependency boundary. Move the Notes registration into it rather than registering the same target twice. RegisterTypes declares services/views; OnInitialized performs any required startup work. A new folder alone does not establish modularity without contracts and dependency direction.
Use EventAggregator for notifications, not hidden persistence
using Prism.Events;
public sealed class NoteSaved : PubSubEvent<string> { }
// Publisher: called after a successful save.
events.GetEvent<NoteSaved>().Publish(noteId);
// Subscriber: events is an injected IEventAggregator.
var token = events.GetEvent<NoteSaved>().Subscribe(
id => Status = $"Saved: {id}", ThreadOption.UIThread);
// When this subscriber is disposed or no longer needs notifications:
events.GetEvent<NoteSaved>().Unsubscribe(token);
This extension illustrates notifying another panel after a successful save. IEventAggregator is injected. UIThread subscriptions require the appropriate UI context, and subscription lifetime needs an owner; retain the token and unsubscribe when the notification is no longer needed. Turning every service operation into an event makes the execution path harder to follow.
PubSubEvent communicates inside the process. It is not a durable queue or a database transaction, and publishing success after a failed save cannot repair that save. Use a service call when the caller needs a result and a notification when the modules should remain loosely coupled. The module and event fragments are explanatory extensions, not features implemented by the small runnable project.
Asynchronous work and useful verification boundaries
Replacing the memory store with an HTTP save requires more than an async lambda in DelegateCommand. Async command behavior, IsBusy, duplicate-click prevention, cancellation and visible error handling need to be designed together. An await does not by itself make background-thread collection updates safe. This synchronous example does not pretend to solve those additional problems.
- Blank or whitespace titles should make CanExecute false; a valid title should enable it.
- Adding a note should store/display the trimmed value and reset Title.
- A second ViewModel using the same singleton store should read the previous note.
- XAML should compile, and the Notes target, MainRegion name and ViewModel conventions should remain consistent.
To run the example, place the code blocks in the corresponding files of the WPF project created in the setup steps. A persistent version could replace INoteStore with an API adapter without distributing database passwords or a Supabase server key to the desktop application. Prism’s benefit is an identifiable place to change behavior, rather than the number of framework classes in the project.