In August 2024 Microsoft handed the Mono Project to WineHQ, whose whole purpose is running Windows binaries on machines that are not Windows. This is about what .NET had to cut off to get out of Windows: app domains, remoting, COM+ and server-side WCF were never libraries, they were the operating system wearing library clothes. And for a finished internal application, migrating is still the wrong answer.
23 October 2024·9 min read·languagesdependencies
It is easy to forget how thoroughly the .NET Framework belonged to the operating system. You did not ship it. It was already there, installed once per machine, patched by Windows Update on Microsoft's schedule rather than yours, and shared by every managed application on the box. An assembly you wanted several programs to use went into the global assembly cache, signed and versioned, resolved by policy an administrator could override in a machine-level configuration file. So did the registry, which is where half of everything was.
The modern arrangement inverts all of it. A .NET application either resolves a framework reference against runtimes installed side by side, so several versions coexist, or it is published self-contained and brings its own runtime in the folder with it. Nothing is shared, nothing is registered, and there is no machine-wide policy to override. The upgrade stopped being an event for the server and became a property of your build.
That inversion is what people mean when they say the migration was hard, though it is rarely what they say. The compiler was never the problem and neither was the language. What broke is that the Framework's most useful features were not libraries sitting on top of Windows. They were Windows, exposed through a managed API, and there is no version of "make it run on Linux" that keeps them. Going cross-platform meant deciding which of them to cut off.
Application domains did not cross. AppDomains isolated code inside one process, which is how plugin hosts, per-tenant sandboxes and unloadable extensions were built for fifteen years. The documented position is flat: creating more app domains is not supported and there are no plans to add it, because the feature needs runtime support and is expensive. Isolation is now a process or a container, which means an in-process design became a deployment topology.
Remoting went with them. .NET Remoting let objects call across app domains and machines as though the boundary were not there, and the docs describe it as a problematic architecture that also depended on the app domains that are gone. The replacement advice is pipes locally and a plain network protocol between machines. That is not a port, it is a different program, because remoting's promise was that you did not have to think about the wire.
COM+ and Workflow Foundation stayed behind. System.EnterpriseServices, which is COM+, is not supported, and neither is Windows Workflow Foundation. Both are worth naming because the dependency is not on an API but on an installed Windows subsystem with its own configuration and its own administration tool. A transaction coordinator somebody configured in 2009 does not get reimplemented, and a workflow definition sitting in a database is a data migration wearing a code migration's clothes.
WCF lost the side that mattered. Client-side WCF crossed over. The server side did not, and landed instead in CoreWCF, a community project Microsoft supports rather than a part of the platform. If you exposed services over anything but basic HTTP bindings, this is the item that decides your schedule. Rewriting them as HTTP or gRPC is usually right, and it is a change to your public interface, which makes it a change to everybody who calls you.
Each of those features needed something from below: a runtime service, an OS subsystem, a security model, a machine-wide store of configuration. They looked like libraries because they arrived as assemblies with namespaces, and a library is a thing you can reimplement. What they actually were is a binding to the host, and a binding does not survive being moved to a different host. That is why an estimate built by counting lines of code is wrong by a category rather than by a percentage.
System.Drawing is the specimen I would keep, because it is the case where somebody tried the shim instead. Drawing in .NET was a thin wrapper over GDI+, a Windows technology, so a cross-platform implementation had to reimplement the Windows parts underneath. Mono's libgdiplus did that, and Microsoft's explanation of the eventual retreat is unusually candid: roughly 30,000 lines of C, largely untested, missing a lot of functionality, with a stream of bugs redirected to it over the years that never got fixed.
So in .NET 6 the package was marked Windows-only, and on anything else you now get a compile-time warning and, at runtime, a type initializer failure with a PlatformNotSupportedException inside it. There was an escape hatch, a switch to turn Unix support back on, and it lasted one release before being removed in 7. Read that as a schedule and it is a fair one. Read it as a strategy and it says the shim did not fail on the day it shipped, it failed slowly, over a decade, on other people's time.
.NET Standard was a bridge, which is to say a symptom. It was a formal list of APIs every implementation promised to provide, so one library could be built once and consumed by the Framework, by Core, by Mono and by Xamarin. It existed because there were several .NET runtimes that did not agree. A specification whose purpose is to hide a divergence is a report on that divergence, and its version numbers measure how far apart the implementations had drifted.
The numbers say where the divergence stopped. Version 2.0 covers 32,638 of the 37,118 APIs in the standard and version 2.1 covers all of them, which reads as progress until you notice the other fact: the .NET Framework never supported 2.1 and never will. The bridge reached its final span and one end stayed on the older bank. From then on, a library author choosing maximum reach was choosing 2.0 permanently and leaving four and a half thousand APIs unused to keep the Framework in the audience.
Skipping version 4 was the loudest quiet decision. When the release after Core 3.1 was announced in May 2019 it was called .NET 5, and the stated reason for skipping 4 was that the 4.x series belongs to the Framework and reusing it would confuse people. The word that matters is not the number, it is the sentence that there will be just one .NET going forward. Dropping "Core" from the name is where the new thing stopped being the alternative and became the platform.
Mono was launched in 2001, before the .NET Framework 1.0 was generally available on Windows, by people who wanted the platform on Linux and were not willing to wait for permission. For most of two decades it was the only answer. Microsoft's own notice puts it well: Mono was the first .NET implementation on Android, on iOS, on Linux and on other operating systems, and it was, in their word, a trailblazer. If you ran C# outside Windows before roughly 2016 you ran it on Mono.
The commercial history bends the other way. Mono's stewards became Xamarin, Xamarin was acquired by Microsoft in 2016, and Microsoft then built its own cross-platform runtime in the open, taking a fork of the Mono runtime into the main .NET repository and progressively moving workloads onto it. The independent implementation had been the argument that portability was possible; once the vendor agreed, the job it existed to do got smaller every year. Its last major release was July 2019 and its last patch release February 2024.
In August 2024 the notice went up on the project's own repository. The WineHQ organization will take over as stewards of the Mono Project, the existing repositories stay readable though some may be archived, binaries remain available for up to four years, and active users are pointed at .NET. It is careful and generous writing. It thanks the developers, says the fork's work is complete, and does not pretend the project has a future in its old form.
I find the destination more eloquent than the announcement. Wine exists to run Windows binaries on systems that are not Windows. Mono existed to run Windows's runtime on systems that were not Windows, was absorbed once that stopped being a rebellion, and has now been handed to the project whose whole purpose is the compatibility problem it was invented to solve. The implementation that proved the platform could leave Windows is filed under running Windows software elsewhere, which is either a demotion or an accurate description.
Some parts move in a week. Domain logic, data access, anything that computes rather than integrates, and most libraries with no Windows types in their public surface. These are a retarget and a build, and the analyzers will tell you where you were relying on something host-specific. If a project is mostly this, the migration is real work but it is scheduled work, and worth doing for the deployment model alone.
Some parts never move. Web Forms has no successor on the new platform, by design, so an application built on it has a rewrite rather than a migration path. If it is a stable internal application that a handful of people use to do a job that has not changed, a rewrite is the most expensive way to end up with what you already have. This is the category where I have most often argued for doing nothing.
There is a tell that you are not porting anything. Try describing the change without the word "upgrade". If the honest description is that a component will talk to its callers differently, or run in a different process, or be deployed by a different team, you are rewriting a boundary and should plan it with those callers in the room. An acme service that quietly became three services during a framework migration is a project that was scoped as a recompile.
The .NET Framework is not deprecated, abandoned or on a countdown. Versions 4.8 and 4.8.1 carry no published end-of-support date at all, because the framework follows the component policy and is serviced as part of the operating system it ships with. Even 4.6.2, which is old, is supported into January 2027, and 3.5 Service Pack 1 runs to 2029. An application on a supported Framework version is not living on borrowed time in any sense a risk register should record.
So consider the common case: an internal application, finished in the sense that it does what it was built to do, changed twice a year for a regulatory field, on Windows servers a team already patches, with no plan to run anywhere else and no performance problem. Every argument I have made is about the cost of carrying Windows assumptions to another host. That application is not going to another host, so the assumptions are not a liability, they are a correct description of where it lives.
I would go further, because a concession that costs nothing is not one. For that class of application I do not think migration is a close call leaning the other way, I think it is the wrong answer outright, and the reasons usually offered are weak. Being on the current thing is not a benefit. Performance improvements are not a benefit when you are not slow. The hiring argument is real and does not survive contact with the cost of a Web Forms rewrite. Move when something forces it, and let the something be a business reason.
What that leaves my claim as is narrower than it started. The break was right for the platform and was necessary rather than brave, because a framework whose hardest parts are operating-system bindings cannot be made portable by any amount of patience. It was right for new code from the first day. For an existing codebase it is a project like any other, competing with every other project, and a vendor making a hard call about its own platform does not make the same call correct for yours.
Inventory the host assumptions before anyone says portable. Walk the dependency list and mark each entry as computation or as a binding to something outside the process: a registry, a service, a security model, a filesystem layout, an installed subsystem. The computation moves. The bindings are the project. This takes an afternoon and it is the difference between an estimate and a guess, and nobody does it because the dependency list looks like it already answered the question.
Say the break out loud, early. The thing Microsoft did well was publish the list of what would not cross, with the reason beside each item, years before most people needed it. That page cost them a difficult announcement and saved everyone a discovery process. If you own a platform and have decided something is not coming with you, the cheapest moment to say so is now, and the most expensive is when somebody finds out during a migration they had already scheduled.
I have described this as one decision and it was closer to ten years of them, made by people who did not know at the start how it would land. .NET Core 1.0 in 2016 was small and awkward and could not do most of what the Framework did; the standard and a long tail of ported APIs came afterward, and much of what looks like strategy was catching up.
What stays with me is the shape of the thing that could not be carried. Not the language, not the libraries, not the millions of lines of application code, all of which turned out to be portable in the ordinary way. It was the assumptions: that there is a registry, that there is one runtime for the machine, that an administrator can override a version policy in a file, that isolation is something the runtime does for you. None of those was ever written down as a dependency, because nobody writes down the ground they are standing on until they try to move.