You have most likely encountered this obstacle if you have ever attempted to convert a long-running.NET Framework program to.NET 8 or.NET10. The code is compiled. Channel Services.In the IDE, RegisterChannel is located directly. AppDomain.The build is green, CreateDomain resolves, IntelliSense is satisfied, and when you run it for the first time, you get:
.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 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.
Here is a classic server registration on .NET Framework:
And the same thing running on .NET 8 or .NET 10:
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:
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.
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:
The server implementation is ordinary code:
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.
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:
No 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.
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:
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.
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.
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:
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.
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.
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
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.
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.
A 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:
This is the part of any compatibility layer that decides whether it is usable. Here it is exhaustive.
virtualThe 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:
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.
new Foo() was routed through activation by the CLR, and there is no hook for that off .NET Framework:
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.
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.
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.
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.
Remoting only:
Application domains — both packages are required:
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.
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:
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.
Claims about a cross-process library are cheap, so here is what actually runs:
Every 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:
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.
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.




