Introduction
If you have ever tried to move a long-lived .NET Framework application to .NET 8 or .NET10, you have probably met this wall. The code compiles. ChannelServices.RegisterChannel is right there in the IDE. AppDomain.CreateDomain resolves, IntelliSense is happy, the build is green — and the first time you run it you get:
System.PlatformNotSupportedException: Remoting is not supported on this platform.
.NET Core removed the implementations of remoting and application domains but kept some of the type names, which produces exactly this experience: a clean compile followed by a runtime failure. The usual advice is "rewrite it as gRPC" or "rewrite it as WCF Core", and for a small surface that is fine. For an application with two hundred MarshalByRefObject-derived types, server-to-client callbacks, lifetime leases, and a plugin loader built on AppDomain.CreateDomain, "rewrite it" is a project, not a migration.
This article describes a different approach: keep the API and replace what is underneath it. I will walk through two libraries that do this — a remoting compatibility layer and a process-backed AppDomain replacement — the design constraints that shaped them, and the places where the platform simply does not allow an exact match.
The code is at github.com/pieroviano/Net4x.Runtime.Remoting. The packages multi-target .NET Standard 2.0, .NET 8 and .NET 10, and the whole solution — libraries, tests and samples — is built and verified against both modern runtimes.
What Actually Has to Be Replaced
Classic remoting is three things stacked together, and each one is unavailable for a different reason.
| Layer | .NET Framework | Why it is gone |
|---|---|---|
| Proxies | CLR transparent proxies (RealProxy) | The runtime hook does not exist off .NET Framework |
| Serializer | BinaryFormatter | Removed from the supported surface; throws outright on .NET 9+ |
| Transport | TcpChannel (SSPI-secured) | The channel plumbing was never ported |
So the replacement needs a proxy mechanism, a [Serializable]-aware serializer that is not BinaryFormatter, and a transport. What it does not need to change is the public API — and that is the whole point of the exercise. MarshalByRefObject, RemotingConfiguration, ChannelServices, ObjRef, ILease, ISponsor and the rest keep their names and signatures.
Part 1: Remoting
Before and After
Here is a classic server registration on .NET Framework:
ChannelServices.RegisterChannel(new TcpChannel(9000), false);
RemotingConfiguration.RegisterWellKnownServiceType(
typeof(OrderService), "orders.rem", WellKnownObjectMode.Singleton);
And the same thing running on .NET 8 or .NET 10:
using System.Runtime.Remoting;
using System.Runtime.Remoting.Channels;
using System.Runtime.Remoting.Channels.Tcp;
ChannelServices.RegisterChannel(new TcpServerChannel("app", 9000), ensureSecurity: false);
RemotingConfiguration.RegisterWellKnownServiceType(
typeof(OrderService), "orders.rem", WellKnownObjectMode.Singleton);
The channel type is named TcpServerChannel rather than TcpChannel — separate client and server channel types were always available in classic remoting, and using them makes the direction explicit. The client side:
ChannelServices.RegisterChannel(new TcpClientChannel(), ensureSecurity: false);
var orders = (IOrderService)RemotingServices.Connect(
typeof(IOrderService), "tcp://server:9000/orders.rem");
RemotingConfiguration.Configure("app.config") still reads a <system.runtime.remoting> section, so if your deployment is configuration-driven rather than code-driven, that keeps working untouched.
A Working Contract
Let me use the sample from the repository, because it exercises the features that usually decide whether a compatibility layer is real or a demo. The contract assembly:
[Serializable]
public class Quote
{
public string Symbol;
public decimal Price;
public DateTime AsOf;
private string _source = "unset"; // private field: must survive the wire
public string Source => _source;
public void SetSource(string s) => _source = s;
}
// The client implements this and hands it to the server; the server calls back into it.public interface ITicker
{
void OnQuote(Quote quote);
void OnClosed(string reason);
}
public interface IMarketService
{
Quote GetQuote(string symbol);
int Subscribe(ITicker ticker, string symbol);
void PublishTo(int subscriptionId, decimal price);
bool TryGetLast(string symbol, out Quote quote); // out parameter
void Accumulate(ref decimal running, decimal delta); // ref parameter
T Echo<T>(T value); // generic method
void Boom(string message); // exception marshalling
string WhoAmI();
}
The server implementation is ordinary code:
public class MarketService : MarshalByRefObject, IMarketService
{
private readonly Dictionary<int, (ITicker Ticker, string Symbol)> _subscriptions = new();
private static int _nextSubscription = 1;
public int Subscribe(ITicker ticker, string symbol)
{
// `ticker` arrived as an ObjRef pointing back at the client. Calling it sends a request
// in the reverse direction over the connection the client already opened.
var id = _nextSubscription++;
_subscriptions[id] = (ticker, symbol);
ticker.OnQuote(GetQuote(symbol));
return id;
}
public T Echo<T>(T value) => value;
public void Boom(string message) => throw new InvalidOperationException(message);
public string WhoAmI() => "server-pid-" + Environment.ProcessId;
}
That WhoAmI is not decoration. It is how you prove a remoting layer is actually remoting: the client asserts that the process id it gets back is not its own. A layer that quietly short-circuits to an in-process call would pass every other test in the suite.
The Callback Improvement
This is the part where the replacement is better than the original, so it is worth dwelling on.
In classic remoting, if you wanted the server to call back into the client, the client had to register its own receiver channel and be externally addressable. That is a firewall problem, a NAT problem, and in a container it is often simply impossible.
Here, the client registers a client channel only:
// A client channel only. Note there is no server channel here at all — callbacks still work.
ChannelServices.RegisterChannel(new TcpClientChannel("sample-client", null), ensureSecurity: false);
var market = (IMarketService)RemotingServices.Connect(typeof(IMarketService), url);
var ticker = new Ticker(); // : MarshalByRefObject, ITickervar subscription = market.Subscribe(ticker, "ACME"); // server now holds a reference to us
market.PublishTo(subscription, 123.75m); // server pushes to us
Console.WriteLine(ticker.Quotes[^1].Price); // 123.75No listening port on the client. This works because the connection is framed, duplex and multiplexed: one TCP connection carries calls in both directions, correlated by request id. When the server invokes ticker.OnQuote(...), the reverse call travels back down the connection the client already opened.
The routing rule that makes this safe is worth stating precisely, because the naive version is wrong. A client with no receiver channel marshals its objects under a bare uri with no scheme — there is nothing to dial. So ConnectionContext publishes the connection a call arrived on for the duration of dispatch, and a reference with no dialable url binds its calls back to that connection. A reference that does carry a channel url is dialed normally. That distinction is what prevents a third-party reference — A hands B a reference to C — from being misrouted through the A↔B connection.
The Serializer
BinaryFormatter is not an option, so the layer carries its own [Serializable]-aware binary serializer. It honours the full classic contract, which is more than most people remember is in there:
private fields and
[NonSerialized]ISerializablewithGetObjectData/ the deserialization constructorIObjectReference,IDeserializationCallback, and the[OnSerializing]/[OnDeserialized]familyserialization surrogates
object cycles and shared references (identity preserved, not duplicated)
arrays: single-dimension, jagged, multi-dimensional, and non-zero lower bound
Two constraints are new, and both exist because the sender should not get to decide how much work the receiver does:
RemotingConfiguration.MaxGraphDepth = 128; // default
RemotingConfiguration.MaxFrameLength = 64 * 1024 * 1024;
The depth bound is not paranoia about malice alone. Reading a graph is recursive, so graph depth is stack depth — 50,000 nested arrays fit in 250 KB of payload and overflow the reader's stack, which you cannot catch and which takes the process with it. Declared element counts are separately checked against the bytes actually remaining in the payload: every element costs at least one byte, so a 10-byte message claiming 100,000,000 elements is lying, and the check catches it before the allocation.
Both bounds are enforced on the writing side too, so a graph that could never be read fails on the side that can still do something about it, with an error message naming the property to raise.
Deserialization Safety
TypeFilterLevel.Low is the default for a network endpoint, matching .NET Framework. There is one deliberate improvement: a deny list that applies at every level, including Full. .NET Framework's Full had no deny list at all.
The deny list names known gadget-chain entry points — types whose deserialization turns into code execution — and matches on the base chain, not the type's own name. That detail matters: matching by name alone made the System.IO.FileSystemInfo entry inert, because it is abstract and only its differently-named subclasses can be constructed.
Two related rules:
Delegates are refused on the general object path. A delegate reached through an object graph is a classic gadget step. There is a dedicated delegate record where the shape is known and the receiver decides whether to invoke; a static delegate is only reconstructed at
TypeFilterLevel.Full.Type names on an incoming message resolve only against already-loaded assemblies.
Type.GetTypeloads assemblies by name, so resolving a wire-supplied name would let a caller choose which assembly the server loads — and run its module initializer — before dispatch. Nothing legitimate is lost: for the server to hold an object satisfying a contract, that contract's assembly is loaded by definition.
TLS
Classic remoting's secure="true" on the TCP channel meant SSPI/Negotiate — Windows authentication, not transport encryption. There is no portable equivalent, so the encryption story here is deliberately different and deliberately named differently:
server.Security = TcpChannelSecurity.ForServer(certificate);
client.Security = TcpChannelSecurity.ForClient("service.example.com");
A client that cannot chain the certificate — a self-signed cert in a closed deployment — opts out with ForClientWithoutValidation(). That is a named setting rather than a validation callback returning true, because the callback-returning-true is the single most common way TLS gets silently disabled in production.
For same-machine communication there is also an IpcChannel speaking the same framed protocol over named pipes, with ipc://portName/objectUri urls. No port means nothing is reachable from off the machine and nothing can collide with another process binding the same port.
Part 2: Application Domains
A Domain Is Now a Process
AppDomain.CreateDomain cannot be revived. The CLR has no second domain to create — this is not a missing API, it is a missing runtime feature. So a domain becomes an operating system process, and the remoting layer above carries the calls.
// Before (.NET Framework)var domain = AppDomain.CreateDomain("plugin");
var plugin = (IPlugin)domain.CreateInstanceAndUnwrap("Acme.Plugin", "Acme.Plugin.Entry");
plugin.Run();
AppDomain.Unload(domain);
// Aftervar domain = AppDomain.CurrentDomain.CreateChildDomain("plugin");
var plugin = (IPlugin)domain.CreateInstanceAndUnwrap("Acme.Plugin", "Acme.Plugin.Entry");
plugin.Run();
domain.Unload();
Only the two statics change, and only because C# has no static extension methods. SetData / GetData, DoCallBack, FriendlyName, BaseDirectory, IsDefaultAppDomain() and CreateInstanceAndUnwrap keep their names and signatures.
Static State, Isolated — the Original Reason Domains Existed
using var first = AppDomain.CurrentDomain.CreateChildDomain("tenant-a");
using var second = AppDomain.CurrentDomain.CreateChildDomain("tenant-b");
var a = first.CreateInstanceAndUnwrap<Plugin>(new object[] { "tenant-a" });
var b = second.CreateInstanceAndUnwrap<Plugin>(new object[] { "tenant-b" });
a.Execute("one");
a.Execute("two");
b.Execute("one");
// Plugin holds a private static int _callsInThisDomain
Console.WriteLine(a.Summarise().Calls); // 2
Console.WriteLine(b.Summarise().Calls); // 1
Console.WriteLine(a.ProcessId != b.ProcessId); // TrueIsolation That the Original Could Not Deliver
This is where a process beats a domain outright. A .NET Framework application domain shared a process, so a stack overflow, a corrupt native heap or an Environment.FailFast in the plugin killed the host along with it. Domains offered code isolation, never fault isolation.
var domain = AppDomain.CurrentDomain.CreateChildDomain("doomed");
var plugin = domain.CreateInstanceAndUnwrap<Plugin>();
try { plugin.Crash(); } // calls Environment.FailFast inside the childcatch (Exception) { /* the connection dies with the process; that is the point */ }
while (domain.IsAlive) Thread.Sleep(50);
try { domain.CreateInstanceAndUnwrap<Plugin>(); }
catch (AppDomainUnloadedException) { /* expected */ }
// ...and this application is still running.using var healthy = AppDomain.CurrentDomain.CreateChildDomain("recovered");
The parent observes AppDomainUnloadedException — the same exception classic code already handled — and carries on. Nothing in the calling code needs to know that the mechanism changed.
DoCallBack, and Why a Lambda Cannot Work
public static class Settings
{
// Target of a DoCallBack. Must be static — a closure cannot cross the boundary.
public static void ApplyDefaults()
{
Write("mode", "configured-by-callback");
Write("pid", Process.GetCurrentProcess().Id.ToString());
}
}
domain.DoCallBack(Settings.ApplyDefaults); // runs INSIDE the domainA lambda that captures local state compiles to a closure object which has no identity the child process can reach, so it is rejected with an explanation rather than silently misbehaving:
var captured = 1;
domain.DoCallBack(() => captured++); // throws, message mentions MarshalByRefObjectThe Complete List of Required Source Changes
This is the part of any compatibility layer that decides whether it is usable. Here it is exhaustive.
1. Members on a class contract must be virtual
The CLR's transparent proxy intercepted every member. A generated proxy is a subclass, and a subclass can only override virtual members. A non-virtual one would run locally on the caller and silently return wrong results:
public class OrderService : MarshalByRefObject
{
public virtual Order Get(int id) { ... } // add 'virtual'
}
Two non-obvious points. First, IsVirtual is not the test — an implicit interface implementation is compiled virtual final, which is just as un-overridable, so it is reported too. Second, remoting an interface avoids the issue entirely, because interface slots are separately re-implemented and are therefore always intercepted. An interface-typed contract is the recommended shape.
Rather than allow a silent wrong answer, proxy creation fails at creation time and names every offender. A non-overridable member cannot even be guarded: DefineMethodOverride against a non-virtual method fails at CreateTypeInfo() with TypeLoadException, and a new member is never reached through a base-typed reference. Failing loudly at the earliest possible point is the only honest option.
2. Client-activated objects need an explicit factory
new Foo() was routed through activation by the CLR, and there is no hook for that off .NET Framework:
var session = RemotingActivator.CreateInstance<Session>(userId); // was: new Session(userId)The url-taking form is named CreateInstanceAt rather than being an overload — with both present, CreateInstance<Cart>("owner") binds to the url overload and silently treats a constructor argument as an endpoint address. That bug showed up in the sample within minutes of the overload existing.
3. Domain creation and unload
AppDomain.CurrentDomain.CreateChildDomain("worker") // was: AppDomain.CreateDomain("worker")
domain.Unload() // was: AppDomain.Unload(domain)4. A few members could not keep their signature
Assembly, AppDomainSetup, evidence and — less obviously — AssemblyName cannot cross a process boundary. AssemblyName.GetObjectData throws PlatformNotSupportedException off .NET Framework, which is the sort of thing you only discover by measuring.
| Classic | Replacement |
|---|---|
| AppDomainSetup | AppDomainSetup2 — same property names; the platform owns the original type on .NET 8 or .NET 10 |
| domain.Load(name) → Assembly | domain.LoadAssembly(name) → AssemblyName |
| domain.GetAssemblies() → Assembly[] | domain.GetLoadedAssemblies() → AssemblyName[] |
| AssemblyResolve returning Assembly | AssemblyResolve returning a path or raw bytes |
| domain.SetupInformation | domain.Setup |
Settings with no meaning off .NET Framework — ShadowCopyFiles, LoaderOptimization, evidence, permission sets — are deliberately not declared at all. A settable property that silently does nothing is a latent bug; a compile error is a prompt to think about what the code actually needed.
5. Suppress two obsoletion warnings
<NoWarn>$(NoWarn);SYSLIB0010;CS0672</NoWarn>MarshalByRefObject.InitializeLifetimeService is [Obsolete] on modern .NET (SYSLIB0010) and overriding it also raises CS0672. Both are harmless here: the library calls the override itself and handles the PlatformNotSupportedException the base implementation throws — which is what lets a class that returns its own lease keep working, and one that defers to base keep working too.
That is the whole list.
What You Give Up
An honest accounting matters more than a feature table, so:
No wire compatibility with unmodified .NET Framework peers. Both ends must reference these packages. This was the deliberate trade that allowed a clean protocol and a serializer that is not
BinaryFormatter.No AOT, no full trimming. Proxies are generated with
Reflection.Emit.No HTTP/SOAP channel, no
ContextBoundObject, no context attributes. SOAP serialization has no supported equivalent off .NET Framework;ref="http"in a config file throws and namestcpandipcas the alternatives.Domains cost more to start — roughly 50–100 ms against about 1 ms. Pool and reuse them rather than creating one per unit of work.
A domain is not a security boundary. Domains talk over loopback TCP bound to
127.0.0.1with unguessable capability uris. That is hardening, not isolation — another process running as the same user can still reach one. Do not use domains to sandbox code you do not trust.One thread per in-flight call.
IMessageSink.SyncProcessMessageis synchronous by contract, so a caller blocks a thread until the reply arrives. A call graph that bounces between processes consumes a thread per hop, and the thread pool injects new threads at roughly one per 500 ms once saturated — which surfaces as calls that take seconds and then recover. Deployments with deep callback chains should raiseThreadPool.SetMinThreads. Fixing this properly would mean abandoning theIMessageSinkshape, and that shape is what makes this a compatibility layer rather than a new framework.Emitted proxy types are never unloaded. The factory uses
AssemblyBuilderAccess.Runand caches each contract's proxy for the process lifetime, which is what makes the second call cheap. Bounded by the number of distinct contracts — a deployment constant, not a function of traffic.Lifetime leases are lazy. A lease is created only when something asks for one, so a published object with no lease is never reaped. A long-lived domain that creates a worker per unit of work must call
domain.Release(instance); nothing else will.
Installing
Remoting only:
<PackageReference Include="Net4x.Runtime.Remoting" Version="1.0.0" />Application domains — both packages are required:
<PackageReference Include="Net4x.AppDomain" Version="1.0.0" /><PackageReference Include="Net4x.AppDomain.Host" Version="1.0.0" />Net4x.AppDomain is the API you compile against. Net4x.AppDomain.Host contains no API at all — it delivers the host program, one process of which is started per domain. It has to be a separate package because NuGet never copies a tools/ folder into your output directory, and a bare executable in lib/ would arrive without the runtimeconfig.json that dotnet exec needs. The host package carries build targets that place the complete host at tools/net8.0/ and tools/net10.0/ in your output folder, which is where the launcher looks.
Reference Net4x.AppDomain alone and it compiles fine, then throws on the first CreateChildDomain with a message listing every path it searched. Fail loudly, and name the fix.
The Host Must Match Your Runtime
Both host builds ship, and the launcher picks the one matching the runtime the parent is on: an exact match first, then the closest older build, then a newer one. That ordering is not cosmetic. A child process running .NET 8 cannot load an assembly built for .NET 10 — and the failure does not say so. The assembly loads at metadata level and then Assembly.GetType simply returns null, so what you get is:
Type 'Acme.Plugin.Entry' was not found in assembly 'Acme.Plugin' inside domain 'plugin'.
A type-not-found message for a type that plainly is in the assembly, with nothing pointing at the real cause. It is worth knowing the shape of this if you ever hand-deploy the host or pin it to one framework: the host under tools/ has to be at least as new as the code you are loading into it. The library now resolves types in the child with throwOnError: true so the underlying load failure is named, and the error mentions the host's runtime version and the tools/<tfm> layout.
How It Is Verified
Claims about a cross-process library are cheap, so here is what actually runs:
dotnet build Net4x.Runtime.Remoting.slnx -c Release
dotnet test Net4x.Runtime.Remoting.Tests\Net4x.Runtime.Remoting.Tests.csproj # 144 tests
dotnet test Net4x.AppDomain.Tests\Net4x.AppDomain.Tests.csproj # 49 tests, real processes
pwsh -File samples\run-cross-process-sample.ps1 # 17 cross-process checks
pwsh -File samples\run-appdomain-sample.ps1 # 24 domain checksEvery line of that runs twice, once per target framework: the test projects multi-target net8.0 and net10.0, and both sample scripts loop over the two. That is not redundancy. A domain test only exercises the host build for its own framework, so running only the newest one would leave the .NET 8 host completely untested while still shipping it in the package.
The unit tests alone cannot catch a broken cross-process path. Every one of the 49 domain tests spawns real child processes, the cross-process sample starts two separate executables talking over a real socket, and both compare Environment.ProcessId across the boundary — so they fail if anything is ever quietly served in-process. The cross-process sample is also the only thing that puts the contract in a third assembly, which is what catches an over-strict change to type or method resolution.
Latest cross-process run:
PASS simple call returns a value
PASS decimal survives the wire
PASS DateTime survives with its Kind
PASS private field round-trips
PASS call executed in the server process <- proves it is not an in-process shortcut
PASS out parameter comes back
PASS ref parameter comes back
PASS generic method (string)
PASS generic method (int)
PASS server exception propagates with type and message
PASS subscribe returned an id
PASS server called back into the client during Subscribe
PASS server pushed a later callback
PASS callback carried the pushed payload
PASS client-activated object keeps per-client state
PASS client-activated constructor argument was honoured
PASS each activation is a distinct instance
ALL CHECKS PASSED
Three Bugs Worth Knowing About
Because they are the kind you would hit yourself building anything similar, and each one produced a silently wrong answer rather than an exception.
Array type names on the wire. The assembly qualifier must go after the array / byref / pointer suffix. System.Int32, MyAsm[] parses back as int, not int[] — so every array-typed payload was quietly corrupted until the suffix order was fixed.
Open generic definitions. A generic method's metadata table entry is the open definition. Passing it across the wire delivers an unbound T whose parameter types have no resolvable names, and every generic call fails server-side. It must be closed at the call site.
IsVirtual as the interceptability test. As above: virtual final passes IsVirtual and cannot be overridden. Sealed overrides and implicit interface implementations sailed through the check and then ran locally on the client.
Conclusion
Not every migration should keep its remoting. If your remote surface is a handful of service calls, gRPC or ASP.NET Core minimal APIs will give you a better result and a smaller dependency footprint.
But "rewrite the distribution layer" is not always a proportionate answer to "we want to run on a supported runtime". When the remoting surface is large, deeply entangled with MarshalByRefObject identity semantics, or reliant on lifetime leases and bidirectional callbacks, a compatibility layer lets you move to .NET 8 or .NET 10 first and modernise the architecture afterwards — as a choice rather than as a prerequisite.
And in two places the replacement is simply better than what it replaces. Callbacks no longer require a reachable, addressable client, so a client behind NAT or in a container can receive server pushes over the connection it already opened. And domain isolation is now real fault isolation: a plugin that hard-crashes its process can no longer take your application down with it.
Source, samples and the full deviation list: Net4x.Runtime.Remoting
Jasen FiciPosted Aug 7, 2026, 1:33 PM
Thanks for sharing this — we included it in DotNetNews here: https://dotnetnews.co/archive/the-net-news-daily-issue-514/