Building an Application State Machine in .NET MAUI
๐งฌ Building an Application State Machine in .NET MAUI
As .NET MAUI applications grow, application behavior can become surprisingly difficult to coordinate.
A production mobile app may need to know whether it is:
- ๐ Starting
- ๐ Waiting for authentication
- ๐ฅ Loading required data
- โ Ready
- ๐ต Offline
- ๐ค In the background
- ๐ Synchronizing
- โ ๏ธ Degraded
- ๐ฅ Recovering from an error
Without a clear model, this logic often becomes scattered across App.xaml.cs, ViewModels, pages, services, lifecycle handlers, and navigation code.
Eventually, the application starts asking the same question in dozens of places:
if (isAuthenticated && isInitialized && !isOffline && !isLoading)
{
// What exactly is the application allowed to do here?
}
A cleaner approach is to represent the application lifecycle as an explicit state machine.
Starting
โ
โผ
Initializing
โ
โโโโโโโโโโโโโโโบ AuthenticationRequired
โ
โผ
Ready
โ
โโโโโโโโโโโโโโโบ Offline
โ
โโโโโโโโโโโโโโโบ Background
โ
โโโโโโโโโโโโโโโบ Recovering
Instead of several unrelated flags determining behavior, the application has a defined state and a controlled set of transitions.
Let's build a lightweight state machine for .NET MAUI. ๐งฌ๐
- ๐ง Why Use an Application State Machine?
Consider an application with these flags:
bool IsInitialized;
bool IsAuthenticated;
bool IsOffline;
bool IsBusy;
bool IsBackgrounded;
bool HasCriticalError;
Those six booleans already allow dozens of theoretical combinations.
Some combinations may make no sense:
IsInitialized = false
IsAuthenticated = true
IsOffline = false
IsBackgrounded = true
HasCriticalError = true
What should the application do?
Which screen should it display?
Should synchronization run?
Can navigation occur?
A state machine replaces ambiguous combinations with explicit states:
Starting
Initializing
AuthenticationRequired
Ready
Offline
Background
Recovering
Failed
Now the application can reason about one primary state.
- ๐งฉ Defining Application States =================================
Start with an enum:
public enum ApplicationState
{
Starting,
Initializing,
AuthenticationRequired,
Ready,
Offline,
Background,
Recovering,
Failed
}
These states should represent meaningful operational modes, not every tiny UI condition.
For example:
Application State
โโโ Ready
UI State
โโโ LoadingProducts
โโโ EditingOrder
โโโ ShowingDialog
The application state machine should coordinate application-level behavior.
ViewModels can continue managing feature-specific UI state independently.
- ๐ Defining Transitions ==========================
A state machine is more than an enum.
The important part is defining which transitions are legal.
For example:
Starting
โ
โผ
Initializing
โ
โโโโบ AuthenticationRequired
โ
โโโโบ Ready
โ
โโโโบ Failed
AuthenticationRequired
โ
โโโโบ Ready
Ready
โ
โโโโบ Offline
โ
โโโโบ Background
โ
โโโโบ Recovering
โ
โโโโบ Failed
Offline
โ
โโโโบ Ready
โ
โโโโบ Background
Background
โ
โโโโบ Recovering
Recovering
โ
โโโโบ Ready
โ
โโโโบ Offline
โ
โโโโบ Failed
This prevents accidental transitions such as:
Starting โ Background
or:
Failed โ Ready
unless the architecture explicitly supports them.
- โ๏ธ Creating the State Machine ================================
Define an abstraction:
public interface IApplicationStateMachine
{
ApplicationState CurrentState { get; }
event EventHandler<ApplicationState>? StateChanged;
bool CanTransitionTo(ApplicationState newState);
Task TransitionToAsync(
ApplicationState newState,
CancellationToken cancellationToken = default);
}
Then implement it:
public sealed class ApplicationStateMachine
: IApplicationStateMachine
{
private readonly SemaphoreSlim _transitionLock = new(1, 1);
private readonly ILogger<ApplicationStateMachine> _logger;
private ApplicationState _currentState =
ApplicationState.Starting;
public ApplicationState CurrentState => _currentState;
public event EventHandler<ApplicationState>? StateChanged;
public ApplicationStateMachine(
ILogger<ApplicationStateMachine> logger)
{
_logger = logger;
}
public bool CanTransitionTo(ApplicationState newState)
{
return (_currentState, newState) switch
{
(ApplicationState.Starting,
ApplicationState.Initializing) => true,
(ApplicationState.Initializing,
ApplicationState.AuthenticationRequired) => true,
(ApplicationState.Initializing,
ApplicationState.Ready) => true,
(ApplicationState.Initializing,
ApplicationState.Failed) => true,
(ApplicationState.AuthenticationRequired,
ApplicationState.Ready) => true,
(ApplicationState.Ready,
ApplicationState.Offline) => true,
(ApplicationState.Ready,
ApplicationState.Background) => true,
(ApplicationState.Ready,
ApplicationState.Recovering) => true,
(ApplicationState.Ready,
ApplicationState.Failed) => true,
(ApplicationState.Offline,
ApplicationState.Ready) => true,
(ApplicationState.Offline,
ApplicationState.Background) => true,
(ApplicationState.Background,
ApplicationState.Recovering) => true,
(ApplicationState.Recovering,
ApplicationState.Ready) => true,
(ApplicationState.Recovering,
ApplicationState.Offline) => true,
(ApplicationState.Recovering,
ApplicationState.Failed) => true,
_ => false
};
}
public async Task TransitionToAsync(
ApplicationState newState,
CancellationToken cancellationToken = default)
{
await _transitionLock.WaitAsync(cancellationToken);
try
{
if (_currentState == newState)
return;
if (!CanTransitionTo(newState))
{
throw new InvalidOperationException(
$"Invalid application state transition: " +
$"{_currentState} -> {newState}");
}
var previousState = _currentState;
_currentState = newState;
_logger.LogInformation(
"Application state changed from {PreviousState} to {NewState}",
previousState,
newState);
StateChanged?.Invoke(this, newState);
}
finally
{
_transitionLock.Release();
}
}
}
The SemaphoreSlim is important because several asynchronous events may attempt to change state simultaneously.
For example:
ConnectivityChanged
โ
โโโโโโโ
โผ
Lifecycle Resume โโโบ State Machine
โฒ
โโโโโโโ
AuthenticationChanged
The transition lock ensures these transitions are serialized.
- ๐ Application Initialization ================================
The state machine becomes especially useful during startup.
public sealed class ApplicationInitializer
{
private readonly IApplicationStateMachine _stateMachine;
private readonly IAuthenticationService _authenticationService;
private readonly IDataInitializationService _dataService;
public ApplicationInitializer(
IApplicationStateMachine stateMachine,
IAuthenticationService authenticationService,
IDataInitializationService dataService)
{
_stateMachine = stateMachine;
_authenticationService = authenticationService;
_dataService = dataService;
}
public async Task InitializeAsync(
CancellationToken cancellationToken = default)
{
await _stateMachine.TransitionToAsync(
ApplicationState.Initializing,
cancellationToken);
try
{
await _dataService.InitializeAsync(
cancellationToken);
if (!await _authenticationService
.IsAuthenticatedAsync(cancellationToken))
{
await _stateMachine.TransitionToAsync(
ApplicationState.AuthenticationRequired,
cancellationToken);
return;
}
await _stateMachine.TransitionToAsync(
ApplicationState.Ready,
cancellationToken);
}
catch
{
await _stateMachine.TransitionToAsync(
ApplicationState.Failed,
cancellationToken);
throw;
}
}
}
Startup now has a clear flow:
Starting
โ
โผ
Initializing
โ
โโโ Not Authenticated โโโบ AuthenticationRequired
โ
โโโ Success โโโโโโโโโโโโโบ Ready
โ
โโโ Error โโโโโโโโโโโโโโโบ Failed
This is much easier to reason about than several independent flags.
- ๐ถ Connectivity as a State Transition ========================================
Network changes can also influence application state.
public sealed class ConnectivityStateCoordinator
{
private readonly IApplicationStateMachine _stateMachine;
public ConnectivityStateCoordinator(
IApplicationStateMachine stateMachine)
{
_stateMachine = stateMachine;
}
public async Task ConnectivityChangedAsync(
NetworkAccess networkAccess)
{
if (networkAccess != NetworkAccess.Internet &&
_stateMachine.CurrentState == ApplicationState.Ready)
{
await _stateMachine.TransitionToAsync(
ApplicationState.Offline);
return;
}
if (networkAccess == NetworkAccess.Internet &&
_stateMachine.CurrentState == ApplicationState.Offline)
{
await _stateMachine.TransitionToAsync(
ApplicationState.Ready);
}
}
}
The important distinction is that connectivity is being translated into application semantics.
Network unavailable
โ
โผ
Connectivity Coordinator
โ
โผ
Application State Machine
โ
โผ
Offline
Your ViewModels don't all need to subscribe independently to connectivity events.
- ๐ค Lifecycle Integration ===========================
The same principle applies to lifecycle events.
When the application moves to the background:
Ready
โ
โผ
Background
When it returns:
Background
โ
โผ
Recovering
โ
โโโ Refresh session
โโโ Check connectivity
โโโ Refresh stale data
โโโ Validate dependencies
โ
โผ
Ready
This is more robust than immediately assuming that an application returning to the foreground is ready for use.
For example:
public async Task ResumeAsync(
CancellationToken cancellationToken = default)
{
await _stateMachine.TransitionToAsync(
ApplicationState.Recovering,
cancellationToken);
try
{
await RefreshSessionAsync(cancellationToken);
await RefreshStaleDataAsync(cancellationToken);
var targetState =
Connectivity.Current.NetworkAccess ==
NetworkAccess.Internet
? ApplicationState.Ready
: ApplicationState.Offline;
await _stateMachine.TransitionToAsync(
targetState,
cancellationToken);
}
catch
{
await _stateMachine.TransitionToAsync(
ApplicationState.Failed,
cancellationToken);
}
}
- ๐งญ State-Driven Navigation =============================
Navigation can respond to application state.
public sealed class ApplicationNavigationCoordinator
{
private readonly IApplicationStateMachine _stateMachine;
private readonly INavigationService _navigationService;
public ApplicationNavigationCoordinator(
IApplicationStateMachine stateMachine,
INavigationService navigationService)
{
_stateMachine = stateMachine;
_navigationService = navigationService;
_stateMachine.StateChanged += OnStateChanged;
}
private async void OnStateChanged(
object? sender,
ApplicationState state)
{
switch (state)
{
case ApplicationState.AuthenticationRequired:
await _navigationService.GoToLoginAsync();
break;
case ApplicationState.Ready:
await _navigationService.GoToHomeAsync();
break;
case ApplicationState.Failed:
await _navigationService.GoToRecoveryAsync();
break;
}
}
}
For production code, be careful with async void event handlers. Exceptions should be explicitly handled or the notification mechanism can be redesigned around asynchronous observers.
The architectural idea is more important:
State transition
โ
โผ
Navigation Coordinator
โ
โผ
Navigation Decision
Navigation becomes a consequence of state rather than the source of state.
- ๐ฏ Adding Transition Actions ===============================
Sometimes entering a state requires work.
For example:
Enter Offline
โ
โโโ Pause synchronization
โโโ Notify UI
Enter Background
โ
โโโ Persist state
โโโ Pause expensive services
Enter Recovering
โ
โโโ Validate session
โโโ Refresh dependencies
Instead of putting everything inside the state machine, use handlers:
public interface IApplicationStateHandler
{
ApplicationState State { get; }
Task EnterAsync(
CancellationToken cancellationToken = default);
}
Example:
public sealed class OfflineStateHandler
: IApplicationStateHandler
{
private readonly ISynchronizationService _syncService;
public ApplicationState State =>
ApplicationState.Offline;
public OfflineStateHandler(
ISynchronizationService syncService)
{
_syncService = syncService;
}
public Task EnterAsync(
CancellationToken cancellationToken = default)
{
return _syncService.PauseAsync(
cancellationToken);
}
}
This prevents the central state machine from becoming a giant service with dozens of dependencies.
- ๐ Authentication Transitions =================================
Authentication fits naturally into the model.
AuthenticationRequired
โ
โ Login succeeds
โผ
Ready
Session expiration could produce the opposite transition:
Ready
โ
โ Session expires
โผ
AuthenticationRequired
If that transition is valid for your application, add it explicitly:
(ApplicationState.Ready,
ApplicationState.AuthenticationRequired) => true,
This is one of the biggest benefits of the pattern.
Behavior that was previously implicit becomes visible in the transition model.
- ๐งฏ Handling Failure and Recovery ====================================
A Failed state does not necessarily mean the process must terminate. It may represent:
Database initialization failed
Authentication infrastructure failed
Critical configuration missing
Required service unavailable
State restoration failed
The application can expose recovery:
Failed
โ
โ Retry
โผ
Recovering
โ
โโโ Success โโโบ Ready
โ
โโโ Failure โโโบ Failed
If supported, add:
(ApplicationState.Failed,
ApplicationState.Recovering) => true,
This creates an explicit recovery path rather than scattering retry behavior throughout the UI.
- ๐ฃ Observing State from ViewModels ======================================
ViewModels may need to react to state changes. For example:
public bool IsOffline =>
_stateMachine.CurrentState ==
ApplicationState.Offline;
Or:
private void OnApplicationStateChanged(
object? sender,
ApplicationState state)
{
IsOffline =
state == ApplicationState.Offline;
IsApplicationReady =
state == ApplicationState.Ready;
}
The UI can then display:
Offline Banner
Recovery Screen
Loading Overlay
Authentication Screen
Service Degradation Warning
without independently recreating the application's operational rules.
- ๐ Dependency Injection ===========================
Register the state machine as a singleton:
builder.Services.AddSingleton<
IApplicationStateMachine,
ApplicationStateMachine>();
builder.Services.AddSingleton<
ApplicationInitializer>();
builder.Services.AddSingleton<
ConnectivityStateCoordinator>();
builder.Services.AddSingleton<
ApplicationNavigationCoordinator>();
A singleton is appropriate here because the state represents the application process as a whole.
You generally do not want:
Page A โ State Machine A
Page B โ State Machine B
Service C โ State Machine C
There should be one authoritative application state.
- ๐ Logging State Transitions ================================
State transitions are excellent diagnostic events.
Log:
Starting โ Initializing
Initializing โ Ready
Ready โ Offline
Offline โ Ready
Ready โ Background
Background โ Recovering
Recovering โ Ready
For example:
_logger.LogInformation(
"Application state transition: {PreviousState} -> {CurrentState}",
previousState,
currentState);
If a production issue occurs, a transition history can explain what the application was doing immediately before the failure.
For example:
09:41:02 Starting โ Initializing
09:41:03 Initializing โ Ready
09:43:18 Ready โ Offline
09:43:44 Offline โ Ready
09:48:12 Ready โ Background
09:55:27 Background โ Recovering
09:55:29 Recovering โ Failed
That is much more useful than:
Something went wrong.
- ๐งช Testing the State Machine ================================
State machines are particularly easy to unit test because their rules are explicit.
For example:
[Fact]
public async Task Starting_CanTransitionTo_Initializing()
{
await stateMachine.TransitionToAsync(
ApplicationState.Initializing);
Assert.Equal(
ApplicationState.Initializing,
stateMachine.CurrentState);
}
Invalid transition:
[Fact]
public async Task Starting_CannotTransitionDirectlyToReady()
{
await Assert.ThrowsAsync<InvalidOperationException>(
() => stateMachine.TransitionToAsync(
ApplicationState.Ready));
}
Recovery sequence:
Starting
โ
Initializing
โ
Ready
โ
Background
โ
Recovering
โ
Ready
Tests can verify the entire sequence.
This is one reason state machines work well for complex application orchestration: the allowed behavior becomes testable data rather than implicit control flow.
- โ ๏ธ Avoid Too Many States ============================
A common mistake is turning every possible condition into a state. For example:
ReadyOnlineAuthenticatedNotSyncing
ReadyOnlineAuthenticatedSyncing
ReadyOfflineAuthenticated
ReadyOfflineUnauthenticated
ReadyOnlineRefreshing
This quickly becomes unmanageable.
Instead, separate orthogonal concerns.
For example:
Application State
Ready
Connectivity State
Online
Authentication State
Authenticated
Synchronization State
Synchronizing
Not every piece of application state belongs in the same state machine.
The application state machine should model high-level operational modes.
- ๐ State Machine vs Boolean Flags =====================================
| Boolean Flags | State Machine |
|---|---|
| Easy initially | Requires initial design |
| Invalid combinations possible | Explicit valid states |
| Transitions are implicit | Transitions are controlled |
| Harder to test globally | Easy transition testing |
| Logic becomes scattered | Centralized rules |
| Poor diagnostics | Clear transition history |
| Becomes difficult at scale | Better for complex workflows |
For a small application:
IsBusy
IsLoggedIn
may be perfectly sufficient.
A state machine becomes valuable when application behavior depends on several lifecycle and infrastructure conditions.
- ๐๏ธ Suggested Architecture ==============================
A production implementation might look like:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ .NET MAUI App โ
โโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโ
โ
Events / Conditions
โ
โโโโโโโโโโโโโโโผโโโโโโโโโโโโโโ
โผ โผ โผ
Lifecycle Connectivity Authentication
โ โ โ
โโโโโโโโโโโโโโโผโโโโโโโโโโโโโโ
โผ
โโโโโโโโโโโโโโโโโโโโโโ
โ Application State โ
โ Machine โ
โโโโโโโโโโโฌโโโโโโโโโโโ
โ
State Changed
โ
โโโโโโโโโโโโโโผโโโโโโโโโโโโโ
โผ โผ โผ
Navigation Services UI
Coordinator Coordinator ViewModels
The state machine does not need to perform all application work itself.
Its responsibility is to answer:
What state are we in?
Can we move to another state?
What transition occurred?
Other components decide how to respond.
- ๐ Best Practices =====================
When introducing an application state machine in .NET MAUI:
- ๐งฌ Keep states explicit and meaningful.
- ๐ฆ Define legal transitions.
- ๐ Serialize concurrent transitions.
- ๐งฉ Separate application state from UI state.
- ๐ฑ Integrate lifecycle events through coordinators.
- ๐ถ Translate connectivity changes into application semantics.
- ๐ Model authentication transitions explicitly.
- ๐งญ Keep navigation outside the core state machine.
- โ๏ธ Use state handlers for complex enter/exit behavior.
- ๐ Log every important transition.
- ๐งช Unit test valid and invalid transitions.
- ๐ Keep one authoritative state machine through DI.
- ๐ซ Avoid creating dozens of highly specific states.
- ๐ฅ Define failure and recovery paths.
- ๐ Treat transitions as architectural events rather than random property changes.
๐ฏ Conclusion
As a .NET MAUI application becomes more sophisticated, its behavior is increasingly influenced by lifecycle, authentication, connectivity, initialization, synchronization, and failure conditions.
Without a clear model, those concerns tend to produce scattered conditional logic:
Lifecycle
Connectivity
Authentication
Initialization
Recovery
Navigation
โ
โผ
Dozens of unrelated flags
An application state machine provides a clearer model:
External Events
โ
โผ
Application State Machine
โ
โผ
Controlled Transition
โ
โโโ Navigation
โโโ Services
โโโ UI
โโโ Diagnostics
Instead of asking every component to independently determine what the application is doing, the system maintains an explicit operational state.
The key idea is:
Application behavior becomes easier to reason about when valid states and transitions are modeled explicitly rather than emerging from combinations of unrelated flags.
For small applications, this architecture may be unnecessary.
But when a .NET MAUI application needs to coordinate startup, authentication, connectivity, backgrounding, recovery, and failures, a lightweight state machine can provide a clean and testable foundation without introducing excessive complexity. ๐งฌ๐
Was this useful?
Sign in to react. Guest comments are still welcome.



 Layer in .NET MAUI/RASPMAUI.png)
Comments (0)
No approved comments yet.