<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged maui</title>
<link>https://coderlegion.com/tag/maui</link>
<description>Powered by Question2Answer</description>
<item>
<title>Isolation Is Sometimes a Feature, Not a Smell</title>
<link>https://coderlegion.com/31143/isolation-is-sometimes-a-feature-not-a-smell</link>
<description>&lt;p&gt;Two projects expose similar helpers. One has no project references. It is missing from the solution most developers build every day. The obvious reaction is understandable: share the code, add the reference, and bring the lonely project back into the fold.&lt;/p&gt;
&lt;p&gt;That cleanup can be exactly wrong.&lt;/p&gt;
&lt;p&gt;A recent architecture correction I reviewed was a useful reminder that repository shape is not the whole system. Build agents, installed workloads, runtime origins, signing steps, and release pipelines are part of the architecture too. An isolated .NET MAUI head looked neglected when viewed only from the solution file. Viewed from its release path, the isolation was load-bearing.&lt;/p&gt;
&lt;h3 id=&quot;start-with-the-delivery-graph&quot;&gt;Start With the Delivery Graph&lt;/h3&gt;
&lt;p&gt;Before deduplicating across project boundaries, ask how each project is actually built and shipped.&lt;/p&gt;
&lt;p&gt;In the case I reviewed, a server-focused solution deliberately excluded the mobile head. That kept routine server builds from requiring mobile workloads on every developer machine and CI runner. The mobile app was still real software: a separate pipeline restored, built, signed, and released it independently.&lt;/p&gt;
&lt;p&gt;Adding a shared project reference would therefore do more than remove a few repeated lines. It would expand the mobile restore graph and couple its release path to a project the mobile pipeline had never needed. Adding the mobile project to the server solution would impose a different cost in the other direction: machines building the server product would now need tooling they did not otherwise use.&lt;/p&gt;
&lt;p&gt;Neither consequence is visible in a tidy dependency diagram. Both matter in production delivery.&lt;/p&gt;
&lt;h3 id=&quot;similar-code-can-encode-different-assumptions&quot;&gt;Similar Code Can Encode Different Assumptions&lt;/h3&gt;
&lt;p&gt;The same review contained two link resolvers that appeared ripe for consolidation. The browser implementation derived an external base address from its hosting origin. That was valid because the browser app was served by the web host.&lt;/p&gt;
&lt;p&gt;The mobile app ran inside an embedded web view. Its internal origin did not identify the external service it needed to reach. Reusing the browser resolver would not create elegant sharing; it would import an invalid environment assumption.&lt;/p&gt;
&lt;p&gt;This is a useful test for any proposed abstraction: are the implementations merely similar in syntax, or are they equivalent in authority and context?&lt;/p&gt;
&lt;p&gt;Imagine a browser client and a desktop client that both produce a download URL. The browser can safely resolve a relative path against the page origin. The desktop client may need an explicitly configured service endpoint. A common interface can still be useful, but a common implementation may be actively misleading.&lt;/p&gt;
&lt;p&gt;Duplication is not automatically good. It is sometimes the smallest honest representation of two different worlds.&lt;/p&gt;
&lt;h3 id=&quot;guard-the-constraint-and-its-reason&quot;&gt;Guard the Constraint—and Its Reason&lt;/h3&gt;
&lt;p&gt;Documentation corrected the misleading story, but the stronger improvement was an architecture test suite.&lt;/p&gt;
&lt;p&gt;The tests did not merely assert that the mobile head had no project references and stayed outside the server solution. They also checked that the separate mobile build path still existed. That second half changes the character of the rule.&lt;/p&gt;
&lt;p&gt;Without it, the test says: “keep this isolated forever.”&lt;/p&gt;
&lt;p&gt;With it, the test says: “keep this isolated while this independent delivery path remains true.”&lt;/p&gt;
&lt;p&gt;That is a healthier architectural guard. It protects a decision without pretending the decision is timeless. If the separate pipeline disappears, the guard fails and asks maintainers to reconsider the boundary. The test becomes executable rationale rather than fossilized preference.&lt;/p&gt;
&lt;p&gt;This pattern generalizes well. If a component must stay dependency-free because it is loaded by a restricted host, test both the dependency rule and the existence of that host. If a migration assembly must remain separate because tooling discovers it independently, guard both the assembly boundary and the discovery path.&lt;/p&gt;
&lt;h3 id=&quot;the-trade-off-is-still-real&quot;&gt;The Trade-Off Is Still Real&lt;/h3&gt;
&lt;p&gt;Deliberate isolation has costs.&lt;/p&gt;
&lt;p&gt;Separate helpers can drift. A visual shell copied for a second head may receive fixes later. Architecture tests add maintenance, and source-shape checks can become brittle if they assert incidental text instead of meaningful structure.&lt;/p&gt;
&lt;p&gt;The answer is not to ignore those costs. It is to compare them with the coupling being avoided.&lt;/p&gt;
&lt;p&gt;Prefer the smallest duplication that preserves the boundary. Keep behavioural contracts aligned where the behaviour truly is shared. Document the assumptions that must differ. Review the boundary when release topology changes. And avoid broad claims such as “this project shares nothing” when the important truth is more precise: it ships independently and intentionally owns a narrow set of platform-specific choices.&lt;/p&gt;
&lt;h3 id=&quot;a-practical-review-checklist&quot;&gt;A Practical Review Checklist&lt;/h3&gt;
&lt;p&gt;Before merging apparently duplicate code across application heads, ask:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Which pipeline builds and releases each head?&lt;/li&gt;
&lt;li&gt;What workloads and SDKs does each build environment guarantee?&lt;/li&gt;
&lt;li&gt;Will a new reference expand the restore or signing boundary?&lt;/li&gt;
&lt;li&gt;Do the implementations rely on different origins, storage, identity, or lifecycle rules?&lt;/li&gt;
&lt;li&gt;Can we test the constraint and the continuing fact that justifies it?&lt;/li&gt;
&lt;li&gt;What event should trigger reconsideration of the decision?&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;These questions turn “DRY versus duplication” into a more useful discussion about operational ownership.&lt;/p&gt;
&lt;h3 id=&quot;architecture-includes-the-way-software-leaves-the-repository&quot;&gt;Architecture Includes the Way Software Leaves the Repository&lt;/h3&gt;
&lt;p&gt;The most valuable lesson for me was a leadership one: do not label unusual structure before tracing its history and delivery path.&lt;/p&gt;
&lt;p&gt;An isolated project may be forgotten. It may also be a carefully bounded product whose release assumptions are invisible from the main solution. We should investigate before simplifying, explain the decision in language that cannot be mistaken for neglect, and encode the rationale where future changes will encounter it.&lt;/p&gt;
&lt;p&gt;Clean architecture is not the fewest projects or the fewest repeated lines. It is a system whose dependencies match the realities of how it runs and ships—and whose exceptions are specific, reviewable, and allowed to expire.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31143/isolation-is-sometimes-a-feature-not-a-smell</guid>
<pubDate>Thu, 15 Oct 2026 06:01:03 +0000</pubDate>
</item>
<item>
<title>The Pitfalls of Migrating from Xamarin.Forms to .NET MAUI</title>
<link>https://coderlegion.com/29655/the-pitfalls-of-migrating-from-xamarin-forms-to-net-maui</link>
<description>&lt;h3 id=&quot;why-the-migration-is-not-a-simple-upgrade&quot;&gt;Why the migration is not a simple upgrade&lt;/h3&gt;
&lt;p&gt;On paper, migrating from Xamarin.Forms to .NET MAUI looks like a namespace change. In practice, the time goes into three areas: custom renderers, navigation, and plugins that are no longer maintained. Microsoft's support for Xamarin ended in May 2024, and the apps still on it are getting harder to publish as Apple and Google raise their minimum SDK requirements.&lt;br&gt;
The mechanical part is relatively easy. The project moves to the single-project format, &lt;code&gt;Xamarin.Forms&lt;/code&gt; becomes &lt;code&gt;Microsoft.Maui.Controls&lt;/code&gt;, and &lt;code&gt;Xamarin.Essentials&lt;/code&gt; is built directly into the framework. Automated upgrade tools and AI assistants handle this part well.&lt;br&gt;
What they don't handle well is understanding why a custom renderer existed, what the code assumed about the order in which pages load, or what hidden behavior an old plugin provided. This article walks through those pitfalls, with code examples and solutions that work in real projects.&lt;/p&gt;
&lt;h3 id=&quot;from-renderers-to-handlers&quot;&gt;From renderers to handlers&lt;/h3&gt;
&lt;p&gt;The biggest conceptual change is that MAUI no longer customizes controls through inheritance, but through mappers: dictionaries that link each property of a cross-platform control to the native code that applies it.&lt;br&gt;
In Xamarin.Forms, removing the underline from an &lt;code&gt;Entry&lt;/code&gt; on Android meant writing an entire renderer:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[assembly: ExportRenderer(typeof(BorderlessEntry), typeof(BorderlessEntryRenderer))]
public class BorderlessEntryRenderer : EntryRenderer
{
    public BorderlessEntryRenderer(Context context) : base(context) { }

    protected override void OnElementChanged(ElementChangedEventArgs&amp;lt;Entry&amp;gt; e)
    {
        base.OnElementChanged(e);
        if (Control != null)
            Control.Background = null;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In MAUI, the same effect comes from adding an entry to the existing mapper, usually in &lt;code&gt;MauiProgram.cs&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Microsoft.Maui.Handlers.EntryHandler.Mapper.AppendToMapping(&quot;Borderless&quot;, (handler, view) =&amp;gt;
{
    if (view is not BorderlessEntry)
        return;
#if ANDROID
    handler.PlatformView.Background = null;
#elif IOS || MACCATALYST
    handler.PlatformView.BorderStyle = UIKit.UITextBorderStyle.None;
#endif
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;pitfall-1-mappers-are-global&quot;&gt;Pitfall 1: mappers are global&lt;/h2&gt;
&lt;p&gt;A renderer applied only to the type registered with &lt;code&gt;ExportRenderer&lt;/code&gt;. An entry added to &lt;code&gt;EntryHandler.Mapper&lt;/code&gt; applies to every &lt;code&gt;Entry&lt;/code&gt; in the app. The &lt;code&gt;if (view is not BorderlessEntry) return;&lt;/code&gt; check in the example above is not optional. Without it, every form in the app loses its border, and the bug only shows up during testing, on screens you weren't looking at.&lt;/p&gt;
&lt;h2 id=&quot;pitfall-2-onelementchanged-has-no-direct-equivalent&quot;&gt;Pitfall 2: OnElementChanged has no direct equivalent&lt;/h2&gt;
&lt;p&gt;Old renderers mixed three things in &lt;code&gt;OnElementChanged&lt;/code&gt;: creating the native control, subscribing to events, and unsubscribing. In MAUI these are separate. For a custom handler, the native control is created in &lt;code&gt;CreatePlatformView&lt;/code&gt;, subscriptions go in &lt;code&gt;ConnectHandler&lt;/code&gt;, and cleanup goes in &lt;code&gt;DisconnectHandler&lt;/code&gt;. Moving the old code without splitting it across these three methods usually leads to memory leaks or events that fire twice.&lt;br&gt;
Also watch when &lt;code&gt;DisconnectHandler&lt;/code&gt; gets called. In early MAUI versions it had to be called manually, while newer versions have a configurable policy via &lt;code&gt;HandlerProperties.DisconnectPolicy&lt;/code&gt;. Check the documentation for the .NET version you're using.&lt;/p&gt;
&lt;h2 id=&quot;pitfall-3-the-temptation-of-compatibility-renderers&quot;&gt;Pitfall 3: the temptation of compatibility renderers&lt;/h2&gt;
&lt;p&gt;MAUI lets you register old renderers through &lt;code&gt;AddCompatibilityRenderer&lt;/code&gt;. That's useful for getting the app running quickly, but it's technical debt: the compatibility package is marked obsolete and doesn't benefit from the new pipeline's optimizations. My recommendation is to use it only as an intermediate step, with a clear ticket for each remaining renderer.&lt;/p&gt;
&lt;h2 id=&quot;pitfall-4-the-native-control-type-has-changed&quot;&gt;Pitfall 4: the native control type has changed&lt;/h2&gt;
&lt;p&gt;Old code often cast to specific native classes. In MAUI, &lt;code&gt;handler.PlatformView&lt;/code&gt; may be a MAUI-derived class (for example &lt;code&gt;MauiTextField&lt;/code&gt; on iOS), not the UIKit or Android class you knew. A wrong cast doesn't produce a compile error but an &lt;code&gt;InvalidCastException&lt;/code&gt; at runtime, usually on only one platform.&lt;/p&gt;
&lt;h3 id=&quot;shell-and-navigation&quot;&gt;Shell and navigation&lt;/h3&gt;
&lt;p&gt;Shell existed in Xamarin.Forms too, but many older apps used &lt;code&gt;NavigationPage&lt;/code&gt;, &lt;code&gt;TabbedPage&lt;/code&gt;, and &lt;code&gt;MasterDetailPage&lt;/code&gt;. In MAUI, Shell becomes the recommended approach, and moving to it changes more than the syntax.&lt;/p&gt;
&lt;h2 id=&quot;pitfall-5-shell-doesn-t-accept-every-page-type&quot;&gt;Pitfall 5: Shell doesn't accept every page type&lt;/h2&gt;
&lt;p&gt;A &lt;code&gt;ShellContent&lt;/code&gt; must contain a &lt;code&gt;ContentPage&lt;/code&gt;. A &lt;code&gt;TabbedPage&lt;/code&gt; or &lt;code&gt;FlyoutPage&lt;/code&gt; (formerly &lt;code&gt;MasterDetailPage&lt;/code&gt;) placed inside Shell is not supported. Tabs and the side menu are defined through the Shell structure (&lt;code&gt;TabBar&lt;/code&gt;, &lt;code&gt;Tab&lt;/code&gt;, &lt;code&gt;FlyoutItem&lt;/code&gt;). If the app had tabs nested inside detail pages, the structure has to be rethought, not just moved.&lt;/p&gt;
&lt;h2 id=&quot;pitfall-6-routes-and-parameters&quot;&gt;Pitfall 6: routes and parameters&lt;/h2&gt;
&lt;p&gt;Pages that don't appear in the Shell visual hierarchy must be registered explicitly, otherwise navigation fails at runtime:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Routing.RegisterRoute(nameof(PatientDetailPage), typeof(PatientDetailPage));

await Shell.Current.GoToAsync($&quot;{nameof(PatientDetailPage)}?id={patient.Id}&quot;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two details take the most time. First, the &lt;code&gt;//&lt;/code&gt; prefix means an absolute route and resets the navigation stack, which is right after login but wrong in a list-detail flow. Second, query string parameters are text. For complex objects, use the parameter dictionary (or &lt;code&gt;ShellNavigationQueryParameters&lt;/code&gt; in recent versions, valid for a single navigation) and receive them through &lt;code&gt;IQueryAttributable&lt;/code&gt;, not through manual serialization in the URL.&lt;/p&gt;
&lt;h2 id=&quot;pitfall-7-page-lifecycle&quot;&gt;Pitfall 7: page lifecycle&lt;/h2&gt;
&lt;p&gt;With &lt;code&gt;ContentTemplate&lt;/code&gt;, Shell creates pages the first time they are shown, not when the app starts. Code that assumed a ViewModel already existed (for example, subscribing in a constructor to messages sent by another page) can miss events. In addition, pages in tabs usually stay in memory, so &lt;code&gt;OnAppearing&lt;/code&gt; is called every time you return, not just once. Data-loading logic needs to be idempotent.&lt;/p&gt;
&lt;h2 id=&quot;pitfall-8-dependency-injection-in-pages&quot;&gt;Pitfall 8: dependency injection in pages&lt;/h2&gt;
&lt;p&gt;MAUI has built-in DI, and Shell can resolve pages and ViewModels from the container if they are registered in &lt;code&gt;MauiProgram.cs&lt;/code&gt;. Pay attention to the lifetime you choose: a page registered as a singleton keeps its state between navigations, while a transient one loses it. Code that used &lt;code&gt;DependencyService.Get&amp;lt;T&amp;gt;()&lt;/code&gt; still works, but this is a good moment to move it to constructor injection.&lt;/p&gt;
&lt;h3 id=&quot;plugins-that-no-longer-exist&quot;&gt;Plugins that no longer exist&lt;/h3&gt;
&lt;p&gt;In many projects, plugins block the migration more than your own code does. The Xamarin ecosystem was built on community-maintained packages: some were abandoned, some were folded into the framework, and others were rewritten under a different name. The first step is an inventory of every NuGet package, with a replacement for each one.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Xamarin.Essentials - built into MAUI - namespaces change (&lt;code&gt;Microsoft.Maui.Devices&lt;/code&gt;, &lt;code&gt;Microsoft.Maui.Storage&lt;/code&gt;, &lt;code&gt;Microsoft.Maui.ApplicationModel&lt;/code&gt;, etc.)&lt;/li&gt;
&lt;li&gt;Plugin.Permissions - &lt;code&gt;Permissions&lt;/code&gt; (built in) - similar API, but manifest/Info.plist declarations are still required&lt;/li&gt;
&lt;li&gt;Plugin.Connectivity - &lt;code&gt;Connectivity&lt;/code&gt; (built in)&lt;/li&gt;
&lt;li&gt;Plugin.Media - MediaPicker (built in) - for full camera control, CommunityToolkit.Maui.Camera&lt;/li&gt;
&lt;li&gt;Xamarin.CommunityToolkit - CommunityToolkit.Maui - many APIs renamed; check behaviors and converters one by one&lt;/li&gt;
&lt;li&gt;Rg.Plugins.Popup - Mopups or Popup from CommunityToolkit.Maui - Mopups keeps a similar API, faster migration&lt;/li&gt;
&lt;li&gt;FFImageLoading - standard &lt;code&gt;Image&lt;/code&gt; or community forks for MAUI - the original package is no longer maintained&lt;/li&gt;
&lt;li&gt;Xamarin.Forms.Maps - Microsoft.Maui.Controls.Maps - requires &lt;code&gt;UseMauiMaps()&lt;/code&gt; in &lt;code&gt;MauiProgram.cs&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;SkiaSharp.Views.Forms - SkiaSharp.Views.Maui.Controls - requires UseSkiaSharp()&lt;/li&gt;
&lt;li&gt;Lottie (Xamarin) - &lt;code&gt;SKLottieView&lt;/code&gt; from SkiaSharp.Extended.UI.Maui&lt;/li&gt;
&lt;li&gt;&lt;code&gt;MessagingCenter&lt;/code&gt; - &lt;code&gt;WeakReferenceMessenger&lt;/code&gt; from CommunityToolkit.Mvvm - &lt;code&gt;MessagingCenter&lt;/code&gt; is marked obsolete&lt;/li&gt;
&lt;li&gt;Visual Studio App Center - Sentry, Firebase Crashlytics, etc. - App Center was retired in March 2025&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;pitfall-9-the-plugin-works-but-only-on-one-platform&quot;&gt;Pitfall 9: the plugin “works”, but only on one platform&lt;/h2&gt;
&lt;p&gt;Some packages have versions that install without errors in a MAUI project but cover only some platforms or depend on old native libraries. The build passes, and the problem appears in the iOS build, at publishing time, or on the first run on a real device. Check on NuGet which frameworks each package targets (for example &lt;code&gt;net8.0-android&lt;/code&gt;, &lt;code&gt;net8.0-ios&lt;/code&gt;) and the date of its latest release.&lt;/p&gt;
&lt;h2 id=&quot;pitfall-10-hidden-behaviors&quot;&gt;Pitfall 10: hidden behaviors&lt;/h2&gt;
&lt;p&gt;Old plugins often did things you couldn't see in your code. Plugin.Media, for example, requested permissions on its own and could compress or rotate the image. &lt;code&gt;MediaPicker&lt;/code&gt; doesn't do all of that, so after migration you may get photos several MB in size or with the wrong orientation. Every replacement has to be tested for behavior, not just for compilation.&lt;/p&gt;
&lt;h3 id=&quot;small-pitfalls-that-eat-up-time&quot;&gt;Small pitfalls that eat up time&lt;/h3&gt;
&lt;p&gt;None of the issues below is hard, but together they can add whole days to an estimate.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Image resources&lt;/strong&gt;. In the single project, images live in &lt;code&gt;Resources/Images&lt;/code&gt; and must follow Android rules: lowercase letters, digits and underscores, no hyphens. SVG files are converted to PNG at build time and are referenced in XAML with the &lt;code&gt;.png&lt;/code&gt; extension.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Layouts&lt;/strong&gt;. Default spacing values have changed, &lt;code&gt;RelativeLayout&lt;/code&gt; has moved to the compatibility package, and &lt;code&gt;Frame&lt;/code&gt; is replaced by &lt;code&gt;Border&lt;/code&gt;. Screens look “almost” the same, which is more dangerous than looking obviously broken.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Device APIs&lt;/strong&gt;. &lt;code&gt;Device.RuntimePlatform&lt;/code&gt; becomes &lt;code&gt;DeviceInfo.Platform&lt;/code&gt;, and &lt;code&gt;Device.BeginInvokeOnMainThread&lt;/code&gt; becomes &lt;code&gt;MainThread.BeginInvokeOnMainThread&lt;/code&gt; or &lt;code&gt;Dispatcher&lt;/code&gt;. The old code compiles with warnings, so it's easy to ignore.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;ListView vs. CollectionView&lt;/strong&gt;. &lt;code&gt;ListView&lt;/code&gt; still exists, but &lt;code&gt;CollectionView&lt;/code&gt; is the recommended option and behaves differently for selection, separators and row height.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Compiled bindings&lt;/strong&gt;. Adding x:DataType in XAML brings a visible performance gain and catches, at compile time, wrong bindings that failed silently in Xamarin.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Trimming and AOT&lt;/strong&gt;. Release builds can strip code accessed through reflection (serialization, dynamically configured DI). An app that works perfectly in Debug can stop at startup in Release. Test the Release build on a real device in the first week, not at the end.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;conclusion-a-lower-risk-strategy&quot;&gt;Conclusion: a lower-risk strategy&lt;/h3&gt;
&lt;p&gt;A successful migration starts with the inventory, not the code. The order that has worked best in practice is:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Inventory every NuGet package, custom renderer, effect and DependencyService call, with a replacement decided for each.&lt;/li&gt;
&lt;li&gt;Do the mechanical part (single project, namespaces, Essentials) and get the app compiling, temporarily using compatibility renderers where needed.&lt;/li&gt;
&lt;li&gt;Build and run the Release build on real devices, on both platforms, before any other change.&lt;/li&gt;
&lt;li&gt;Rewrite renderers as handlers or mappers, one at a time, with visual tests on every affected screen.&lt;/li&gt;
&lt;li&gt;Move navigation to Shell only after everything else is stable. It's the change with the biggest impact on behavior.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;MAUI is a cleaner and faster framework than Xamarin.Forms, and the handler model makes customization much simpler once you understand it. The real cost of the migration lies in what the old code did implicitly. If you identify those things early, the estimate stays realistic and the app reaches the stores without surprises.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/29655/the-pitfalls-of-migrating-from-xamarin-forms-to-net-maui</guid>
<pubDate>Sat, 03 Oct 2026 17:04:00 +0000</pubDate>
</item>
<item>
<title>A Lifetime Is a Hold, Not the Worker</title>
<link>https://coderlegion.com/29003/a-lifetime-is-a-hold-not-the-worker</link>
<description>&lt;p&gt;&lt;code&gt;IDisposable&lt;/code&gt; and &lt;code&gt;IAsyncDisposable&lt;/code&gt; make lifetime management feel pleasantly local: acquire something, use it, dispose it. That model is reliable when the acquired value has exclusive ownership of an independent resource.&lt;/p&gt;
&lt;p&gt;It becomes dangerous when several activations share one background worker.&lt;/p&gt;
&lt;p&gt;Mobile applications encounter this more often than it first appears. Returning online can reconcile a capability that is already running. A foreground transition can overlap an earlier activation. A bounded startup wait can stop waiting for slow native work, while that work completes after a newer activation has already joined the same resource.&lt;/p&gt;
&lt;p&gt;If every returned lifetime means “stop the worker,” one participant can tear down work that another still needs. The sharper contract is: a returned lifetime is a hold on shared work. Disposing it releases only that participant’s hold. The worker stops when the final hold leaves.&lt;/p&gt;
&lt;h3 id=&quot;the-bug-hides-behind-a-reasonable-api&quot;&gt;The bug hides behind a reasonable API&lt;/h3&gt;
&lt;p&gt;Imagine a mobile shell with two optional capabilities: a small status refresher and a device-token relay. Each activation returns an asynchronous lifetime to the orchestrator. The orchestrator keeps the latest lifetime and disposes the one it replaces.&lt;/p&gt;
&lt;p&gt;That sounds tidy, but activations are not necessarily independent. The second activation may join the refresher or event relay started by the first. If the first lifetime calls a global &lt;code&gt;StopAsync&lt;/code&gt;, disposing the replaced lifetime stops the shared worker beneath the second activation.&lt;/p&gt;
&lt;p&gt;The state machine can now tell a convincing lie. Its newest activation completed successfully, so the capability still appears ready. Underneath, polling has stopped or the event handler has been removed.&lt;/p&gt;
&lt;p&gt;Warning, cancellation, and stale-completion paths deserve the same scrutiny. Cleanup code is often written defensively in several branches. If each branch performs terminal teardown, one failed attempt can shut down a resource held by a successful attempt.&lt;/p&gt;
&lt;h3 id=&quot;write-the-ownership-sentence-first&quot;&gt;Write the ownership sentence first&lt;/h3&gt;
&lt;p&gt;Before implementing the lifetime, describe what disposal means:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Disposing this value releases this activation’s participation. It does not necessarily stop the shared resource.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That sentence changes the design. Starting becomes “start or join, then take a hold.” Releasing becomes “release this hold, then stop only if it was the last.” Both transitions need one synchronization boundary.&lt;/p&gt;
&lt;p&gt;It is not enough to increment a counter and subscribe in separate steps. Another thread can observe the gap. A release can unsubscribe after a new hold has been counted, or two starts can install duplicate listeners. The zero-to-one decision and the actual start belong in one atomic operation. So do the one-to-zero decision and the stop.&lt;/p&gt;
&lt;p&gt;The hold itself should release at most once. A double &lt;code&gt;Dispose&lt;/code&gt; must not decrement the count twice. A failed start must not record a hold, otherwise the next activation may “join” work that never started.&lt;/p&gt;
&lt;h3 id=&quot;old-holds-must-not-reach-into-new-runs&quot;&gt;Old holds must not reach into new runs&lt;/h3&gt;
&lt;p&gt;Reference counting alone is not sufficient when the resource can stop and restart.&lt;/p&gt;
&lt;p&gt;Suppose an old activation holds generation A. The application moves offline, so generation A is stopped. Later, the application returns online and starts generation B. If the late lifetime from A merely decrements a global count, it can steal a hold from B and stop the restarted worker.&lt;/p&gt;
&lt;p&gt;Bind each hold to the run it joined. Releasing a hold from an ended run becomes a no-op for the current one.&lt;/p&gt;
&lt;p&gt;The orchestrator may need a run identity too. Some stop events invalidate a broad session lease; others, such as backgrounding or going offline, may intentionally leave that session identity unchanged. A slow activation can therefore finish with a lease that still looks current even though the capability run it belonged to has ended. Tracking the capability run separately prevents that late completion from being adopted as ready.&lt;/p&gt;
&lt;p&gt;This distinction is useful beyond mobile software. Connection pools, shared subscriptions, refresh loops, file watchers, and in-process event relays all have “same logical session, different resource run” failure modes.&lt;/p&gt;
&lt;h3 id=&quot;test-transitions-not-only-outcomes&quot;&gt;Test transitions, not only outcomes&lt;/h3&gt;
&lt;p&gt;A happy-path test that starts and stops once proves very little about shared ownership.&lt;/p&gt;
&lt;p&gt;Useful tests make each boundary visible:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;take two holds, release one, and prove the worker remains active;&lt;/li&gt;
&lt;li&gt;release the final hold and prove the worker stops exactly once;&lt;/li&gt;
&lt;li&gt;dispose one hold twice and prove it releases once;&lt;/li&gt;
&lt;li&gt;fail the first start and prove a later acquisition tries again;&lt;/li&gt;
&lt;li&gt;restart the resource and prove an old hold cannot affect the new run;&lt;/li&gt;
&lt;li&gt;complete an abandoned activation after a newer one and prove the newer work survives; and&lt;/li&gt;
&lt;li&gt;repeatedly take, inspect, and release holds from two threads while the count crosses zero.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That last test is important. A single-thread loop can leave mutation survivors because it never exposes the gap between “count says active” and “listener is actually active.” Two competing threads make zero crossings collide with acquisition and release, which is where split synchronization shows itself.&lt;/p&gt;
&lt;p&gt;Concurrency tests still need discipline: bounded rounds, a clear invariant, and no claim that passing them mathematically proves correctness. Their job is to exercise the race window while deterministic tests pin the state-machine rules.&lt;/p&gt;
&lt;h3 id=&quot;the-honest-trade-off&quot;&gt;The honest trade-off&lt;/h3&gt;
&lt;p&gt;Counted holds add a lock, a run identifier, idempotent release state, and more involved tests. They also force a clear distinction between releasing one participant and terminally disposing the owning dependency scope.&lt;/p&gt;
&lt;p&gt;The simpler design is to prohibit overlap. That can be the right choice when the orchestrator can guarantee one activation at a time, cancel it reliably, and wait for completion before starting another. But if native calls can outlive a budget, or reconciliation can join already-running work, the simplicity is imaginary.&lt;/p&gt;
&lt;p&gt;The practical takeaway is broader than reference counting: make ownership semantics part of the public contract. When a lifetime represents participation in shared work, call it a hold and test it like one. A local cleanup action should never silently become a global stop.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/29003/a-lifetime-is-a-hold-not-the-worker</guid>
<pubDate>Tue, 29 Sep 2026 05:58:20 +0000</pubDate>
</item>
<item>
<title>Offline Readiness Is a State-Machine Contract</title>
<link>https://coderlegion.com/27892/offline-readiness-is-a-state-machine-contract</link>
<description>&lt;p&gt;“Make it work offline” often turns into a storage task: serialize the last successful response, read it when the network fails, and move on.&lt;/p&gt;
&lt;p&gt;That is necessary, but it is not the hard part.&lt;/p&gt;
&lt;p&gt;The hard part is deciding what the stored response means across launch, failure, reconnect, configuration change, and session recovery. Once a mobile application depends on runtime configuration, offline readiness becomes a state-machine contract. A cache is only one implementation detail inside that contract.&lt;/p&gt;
&lt;h3 id=&quot;find-the-contradictory-assumptions-first&quot;&gt;Find the contradictory assumptions first&lt;/h3&gt;
&lt;p&gt;Consider a .NET MAUI wrapper hosting a Blazor UI. A lower startup layer may correctly treat a reachability probe as diagnostic: the network can be absent, so failure should not prevent launch. But a higher layer may immediately call a runtime-configuration endpoint and require a successful answer before activating the application scope.&lt;/p&gt;
&lt;p&gt;Each layer looks reasonable in isolation. Together they disagree.&lt;/p&gt;
&lt;p&gt;The lower layer says, “reachability is not a launch gate.” The higher layer says, “no live response, no ready state.” A short outage is then presented as invalid configuration even though nothing has been invalidated.&lt;/p&gt;
&lt;p&gt;This is why I like to draw the complete launch state machine before changing storage. List the states—uninitialised, resolving, ready from live data, ready from fallback data, unavailable—and name the events that move between them. Contradictions become much easier to see when they are transitions rather than scattered &lt;code&gt;if&lt;/code&gt; statements.&lt;/p&gt;
&lt;h3 id=&quot;classify-failure-before-consulting-the-cache&quot;&gt;Classify failure before consulting the cache&lt;/h3&gt;
&lt;p&gt;Not every failed request grants permission to use stale data.&lt;/p&gt;
&lt;p&gt;A timeout, lost connection, or temporary server error says, “the current answer is unavailable.” A recent cached configuration may be the best safe answer.&lt;/p&gt;
&lt;p&gt;An authoritative not-found or gone response says something different: “this configuration no longer exists here.” Serving the cache would override current server knowledge with old local knowledge. That is not resilience; it is refusal to accept invalidation.&lt;/p&gt;
&lt;p&gt;So the order matters:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Attempt the live load.&lt;/li&gt;
&lt;li&gt;Classify the failure.&lt;/li&gt;
&lt;li&gt;Consider fallback only for the transient class.&lt;/li&gt;
&lt;li&gt;Clear the fallback when an authoritative response makes it wrong.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This distinction is reusable well beyond configuration. Offline permissions, feature manifests, routing metadata, and other control-plane data all need an explicit invalidation rule.&lt;/p&gt;
&lt;h3 id=&quot;make-recent-enough-a-real-policy&quot;&gt;Make “recent enough” a real policy&lt;/h3&gt;
&lt;p&gt;Even a transient failure does not make every cached value safe.&lt;/p&gt;
&lt;p&gt;A useful policy checks at least four things. The entry must belong to the same logical scope. It must come from the same configured source. Its age must be within a deliberate bound. Its timestamp must not be implausibly far in the future.&lt;/p&gt;
&lt;p&gt;Source binding is easy to miss. Runtime configuration often contains addresses and capability switches. If an application is repointed but continues serving a cache captured from the old source, it may look healthy while sending work to the wrong place. Treating a source change as a cache miss is safer and easier to reason about.&lt;/p&gt;
&lt;p&gt;The age bound is a product and operational decision, not a magic constant. It should cover the outages the application is expected to tolerate while still placing a limit on how long retired configuration can survive. Document the reasoning so the next engineer knows whether changing it is a reliability decision or a cosmetic edit.&lt;/p&gt;
&lt;p&gt;Clock skew deserves a rule too. A future-dated entry can otherwise remain “fresh” for far longer than intended.&lt;/p&gt;
&lt;h3 id=&quot;preserve-continuity-when-connectivity-returns&quot;&gt;Preserve continuity when connectivity returns&lt;/h3&gt;
&lt;p&gt;An application that opens from fallback data is usable, but not settled. It should carry an explicit “needs refresh” hint.&lt;/p&gt;
&lt;p&gt;When connectivity returns, the tempting implementation is to rerun the entire startup pipeline. That can be surprisingly destructive. Full reinitialisation may advance a session epoch, cancel in-flight requests, clear actor state, restart background rails, and remount the renderer. On a flapping connection, users can pay that cost repeatedly.&lt;/p&gt;
&lt;p&gt;Prefer an in-place refresh while the current scope remains valid. Update the runtime configuration, clear the refresh hint, and let the active session continue. Escalate to full resolution only if the refresh proves that the scope disappeared or changed in a way that makes continuity unsafe.&lt;/p&gt;
&lt;p&gt;This gives reconnect two distinct transitions:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;unavailable to online: resolve fully because there is no active scope to preserve;&lt;/li&gt;
&lt;li&gt;ready-from-fallback to online: refresh in place because there is useful state to protect.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Those transitions may share a network event, but they should not share an indiscriminate reset.&lt;/p&gt;
&lt;h3 id=&quot;let-storage-fail-softly-and-test-the-wiring&quot;&gt;Let storage fail softly—and test the wiring&lt;/h3&gt;
&lt;p&gt;Device storage is another dependency, not a certainty. Reads can fail, writes can fail, and cleanup can fail. If a cache exists to make cold start more resilient, an escaping storage exception must not become a new cold-start crash.&lt;/p&gt;
&lt;p&gt;Fail-soft behaviour should be intentional: a failed read behaves like a miss, a failed write loses future offline convenience, and a failed clear remains bounded by the expiry policy. Cancellation is different and should still propagate.&lt;/p&gt;
&lt;p&gt;Test the serializer separately with round trips. A cache that writes successfully but cannot read its own format is indistinguishable from having no fallback at all.&lt;/p&gt;
&lt;p&gt;Also test dependency-injection resolution through the real container. Optional constructor dependencies are convenient, but they can compile and quietly fall back to a null implementation when registration is missing. A focused integration test proves that the platform adapter actually reaches the service.&lt;/p&gt;
&lt;h3 id=&quot;the-trade-off-is-explicit-complexity&quot;&gt;The trade-off is explicit complexity&lt;/h3&gt;
&lt;p&gt;This design is more complex than “try the network, then read a file.” It introduces failure classification, cache provenance, expiry, clock rules, lifecycle state, reconnect transitions, serialization, and a wider test matrix.&lt;/p&gt;
&lt;p&gt;That complexity is buying something concrete: continuity without pretending stale configuration is always correct.&lt;/p&gt;
&lt;p&gt;The practical review question is not, “Do we have a cache?” Ask instead:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Which state are we in, what evidence permits this fallback, and what is the least disruptive path back to live truth?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;If the implementation can answer that clearly, offline readiness has become a deliberate contract rather than an accidental side effect.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/27892/offline-readiness-is-a-state-machine-contract</guid>
<pubDate>Mon, 21 Sep 2026 06:07:42 +0000</pubDate>
</item>
<item>
<title>DevLog 20260122: Some Notes on MAUI, Unity, and Building &quot;Map-Like&quot; App Across Platforms</title>
<link>https://coderlegion.com/10309/devlog-20260122-some-notes-on-maui-unity-and-building-map-like-app-across-platforms</link>
<description>&lt;p&gt;I've been meaning to write about this topic for a while.&lt;/p&gt;
&lt;p&gt;MAUI looks great on paper. In fact, it has looked good on paper for years. Microsoft went a long way through UI and application frameworks: VBA, WinForms, MFC, WPF, Silverlight, UWP, Blazor, and now MAUI. Each iteration promises unification, abstraction, and better productivity over platform differences.&lt;/p&gt;
&lt;p&gt;My first attempt at MAUI felt deceptively easy. I gave up shortly after.&lt;br&gt;
So did the second attempt.&lt;/p&gt;
&lt;p&gt;This time was different: I had a concrete delivery target. Giving up was no longer an option.&lt;/p&gt;
&lt;h4 id=&quot;the-problem-a-map-like-feature&quot;&gt;The Problem: A &quot;Map-Like&quot; Feature&lt;/h4&gt;
&lt;p&gt;The concrete requirement was simple to state but not easy to implement well: an app with a map-like feature. Not a full GIS stack, but something that looks and behaves like a map - large static textures, panning, zooming, overlays, and user interaction. No library - we are going to implement things from ground up.&lt;/p&gt;
&lt;p&gt;My first instinct was to go cross-platform. After years of writing platform-locked GUIs that die the moment requirements change, I had no interest in repeating that mistake.&lt;/p&gt;
&lt;p&gt;After surveying a wide range of options - inside and outside the C# ecosystem - I narrowed the choices down to:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Unity&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Godot&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;MAUI&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I tried Unity first.&lt;/p&gt;
&lt;h4 id=&quot;unity-smooth-onboarding-rough-reality&quot;&gt;Unity: Smooth Onboarding, Rough Reality&lt;/h4&gt;
&lt;p&gt;Unity deserves credit where it's due. It was the first mobile platform I ever used seriously, and I had already developed an Android game with it years ago.&lt;/p&gt;
&lt;p&gt;Coming back with significantly more experience in C#, general software engineering, and game development, the onboarding felt excellent. The editor is mature, the documentation is decent, and the mental model is comprehensible.&lt;/p&gt;
&lt;p&gt;Within about three days, I felt comfortable again - comfortable enough to implement the prototype. The implementation itself was straightforward (details skipped here), but the result was not.&lt;/p&gt;
&lt;p&gt;On an actual Android device, performance was terrible. Given time pressure, I didn't fully chase down the root cause. A few hours of profiling and hypothesis testing later, I made the call to bail. Whether the issue was overdraw, batching, UI layout, or just the wrong API for the task didn't matter anymore - the risk was too high. (Oh, did I mention the build size was gigantic?)&lt;/p&gt;
&lt;p&gt;I would have liked to include some demo code - at least the Unity version - but time didn't allow for it. That will have to wait for another post or a future amendment.&lt;/p&gt;
&lt;h4 id=&quot;maui-dry-and-that-s-a-compliment&quot;&gt;MAUI: Dry, and That's a Compliment&lt;/h4&gt;
&lt;p&gt;Rewriting the same idea in MAUI worked. Not &quot;worked eventually&quot; - it worked immediately and predictably. (Actually I tried Expo before MAUI, which was very smooth, then I looked at the hundreds of dependencies, and quietly reconsidered)&lt;/p&gt;
&lt;p&gt;That forced me to rethink a long-held assumption (something I had from the CG world): engines optimized for real-time rendering &lt;em&gt;should&lt;/em&gt; be good at texture-heavy workloads. In practice, frameworks optimized for application UIs are often better at static or semi-static content - especially when the interaction model looks more like &quot;pan and zoom&quot; than &quot;render 60 FPS no matter what.&quot;&lt;/p&gt;
&lt;p&gt;MAUI was dry. I wasn't excited to see its &quot;mobile-like&quot; layout on a Windows screen, and I still am not.&lt;br&gt;
But dry was exactly what the project needed.&lt;/p&gt;
&lt;h4 id=&quot;the-hidden-cost&quot;&gt;The Hidden Cost&lt;/h4&gt;
&lt;p&gt;That said, this experience didn't magically make me like MAUI - and that reminds me of Blazor.&lt;/p&gt;
&lt;p&gt;The core issue in both cases is structural. These frameworks attempt to abstract over domains whose constraints diverge in important, non-accidental ways. The abstraction holds only as long as the application stays within a relatively narrow envelope of complexity.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;MAUI Issues&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Cross-platform inconsistencies are everywhere, not edge cases.&lt;/li&gt;
&lt;li&gt;iOS AoT compilation and trimming can cause things to silently stop working.&lt;/li&gt;
&lt;li&gt;Many behaviors that &lt;em&gt;should&lt;/em&gt; be easily configurable (for example, disabling the Flyout) require platform-specific code.&lt;/li&gt;
&lt;li&gt;Debugging failures caused by trimming or linker behavior is disproportionately painful.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Blazor Issues&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Refactoring is far harder than it should be.&lt;/li&gt;
&lt;li&gt;Large component trees quickly become rigid.&lt;/li&gt;
&lt;li&gt;Abstractions that feel elegant early on tend to resist change later.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In both cases, the abstraction breaks - and when it does, you're often forced to drop down into platform-specific or framework-specific escape hatches that defeat the original promise.&lt;/p&gt;
&lt;p&gt;These frameworks scale poorly in complexity, not because they are badly designed, but because they attempt to simplify domains that fundamentally resist unification. (Well, honestly in the case of MAUI I do think there are some design issues)&lt;/p&gt;
&lt;p&gt;In general, MAUI is slightly better, because it's at least well encapsulated - just write platform-specific codes.&lt;/p&gt;
&lt;h4 id=&quot;some-takeaways&quot;&gt;Some Takeaways&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;Game engines are not automatically good UI frameworks. Application frameworks may outperform engines for static, interaction-heavy visuals. In this particular case I haven't tried Godot, but I suspect the same issue wouldn't happen to Godot.&lt;/li&gt;
&lt;li&gt;&quot;Write once, run anywhere&quot; really means &quot;debug everywhere.&quot; And don't expect MAUI can save you effort because platform-specific behaviors are everywhere and it took me lots of time to back trace which seemingly trivial change many commits ago caused the entire application to break; Or, just turn off the magic &lt;code&gt;MtouchUseLlvm&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;MAUI is an OK choice for simple cross-platform mobile apps in C#.&lt;/li&gt;
&lt;li&gt;Unified frameworks seem to trade short-term velocity for long-term friction. In the perfect world, I will use Swift for iOS. &lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In general, I wish framework developers make it clear their limitations so  decision making can be made upfront.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/10309/devlog-20260122-some-notes-on-maui-unity-and-building-map-like-app-across-platforms</guid>
<pubDate>Fri, 23 Jan 2026 03:06:32 +0000</pubDate>
</item>
</channel>
</rss>