COM Components in C#: Consume, Expose, and Stop RCW Leaks

Most teams only meet COM components C# interop when something legacy refuses to die: an Office automation path, a vendor OCX, a C++ service that still speaks IUnknown, or a VBA macro that has to call into a .NET library. The docs explain RCW and CCW. Production still fails on apartment threads, missing ProgIDs, and objects that never release.

I treat COM as a boundary, not a coding style. This post is the practical map I use when I have to consume or expose COM from C# without turning the process into a leaky, STA-hostile mess.

What “COM components C#” actually means

Two directions, one binary contract:

Direction Wrapper You are… Typical pain
Consume COM from .NET RCW (Runtime Callable Wrapper) Calling a native COM server from C# Registration, bitness, lifetime, STA
Expose .NET to COM CCW (COM Callable Wrapper) Letting VBA/C++/scripts call your assembly Guids, visibility, reg-free vs registry, hosting

COM itself is language-agnostic: interfaces (v-tables), reference counting via IUnknown, and activation through the registry or side-by-side manifests. C# does not “speak COM” natively — the CLR builds proxies. That is useful until you forget the proxy is real and the native object has its own lifetime rules.

Official starting points: Microsoft’s COM interop overview and exposing .NET components to COM.

When I still use COM (and when I refuse)

Situation Use COM? Why
Vendor only ships a COM API Yes No choice; isolate it behind your own interface
Excel/Word automation from a service Avoid if possible Office + server sessions are fragile; prefer file SDKs / Graph / Open XML
In-process plugin for a desktop host that loads COM Yes Match the host’s contract; keep surface tiny
Greenfield service-to-service No gRPC, HTTP, or message bus — not IUnknown across the network
Need cross-language in-process on Windows only Maybe COM still wins for some desktop shells; prefer .NET hosting APIs first

Rule of thumb: if the only reason for COM is “we always did it this way,” redesign. If the reason is a locked vendor surface or a host process you do not control, contain COM behind one adapter assembly and never let RCWs leak into domain code.

Consuming COM from C# without the usual footguns

The happy path is still:

  1. Add a COM reference (or tlbimp / embedded interop types).
  2. Activate via type library metadata or Type.GetTypeFromProgID / Activator.CreateInstance.
  3. Call methods on the RCW.
  4. Release deterministically when you are done.
using System.Runtime.InteropServices;

// Prefer a typed interop assembly when you have a stable TLB.
// Dynamic activation is fine for one-off tools; bad as a library default.
var t = Type.GetTypeFromProgID("LegacyVendor.Widget", throwOnError: true);
dynamic widget = Activator.CreateInstance(t)!;

try
{
    widget.Connect("CONFIG=prod");
    var status = (string)widget.GetStatus();
    Console.WriteLine(status);
}
finally
{
    // RCWs are reference-counted on the COM side.
    // Leaving them to finalizers is how long-running hosts accumulate leaks.
    if (Marshal.IsComObject(widget))
        Marshal.FinalReleaseComObject(widget);
}

What I enforce in reviews:

  • Bitness match — 32-bit COM servers need a 32-bit host (or a surrogate). “Works on my laptop” often means x86 VS hosting.
  • STA vs MTA — UI-bound COM (Office, many ActiveX controls) wants an STA thread. A thread-pool worker calling into STA COM without marshaling is a random hang generator.
  • One owner for release — do not pass raw RCWs through async continuations across threads without understanding the apartment.
  • Embedded Interop Types (EmbedInteropTypes) when you only need a slice of a huge PIA and want to avoid shipping PIAs.

Exposing C# as COM components

When VBA, C++, or a legacy runner must call you, you ship a class with stable interface GUIDs and a registration story.

using System.Runtime.InteropServices;

[ComVisible(true)]
[Guid("a1b2c3d4-e5f6-7890-abcd-ef1234567890")]
[InterfaceType(ComInterfaceType.InterfaceIsDual)]
public interface IOrderLookup
{
    string FindOrderId(string externalRef);
}

[ComVisible(true)]
[Guid("b2c3d4e5-f6a7-8901-bcde-f12345678901")]
[ClassInterface(ClassInterfaceType.None)]
[ProgId("Contoso.Integration.OrderLookup")]
public class OrderLookup : IOrderLookup
{
    public string FindOrderId(string externalRef)
    {
        // Keep this thin. Heavy logic stays in non-COM assemblies.
        return OrderService.Resolve(externalRef);
    }
}

Checklist I actually use:

  • [ClassInterface(ClassInterfaceType.None)] — expose only the interfaces you designed; auto-dual class interfaces become versioning landmines.
  • Stable Guid values committed to source control — regenerating GUIDs breaks every client.
  • Separate contract assembly (interfaces) from implementation when multiple hosts version independently.
  • .NET Framework still has the classic “Register for COM Interop” path; on modern .NET, follow the current expose-to-COM guidance (ComWrappers / registration tooling) rather than assuming mscoree host tricks from 2010 still apply unchanged.
  • Prefer reg-free COM (side-by-side manifests) for desktop apps you control end-to-end; use registry registration when the host is Excel or an IT-managed thick client.

Decision guide: how I structure the boundary

Host / VBA / C++  →  COM adapter assembly (CCW or RCW only)
                              ↓
                     Internal .NET interfaces (no COM attributes)
                              ↓
                     Domain services, HTTP, DB, messaging
Choice Prefer Avoid
Where COM attributes live Thin adapter project Domain entities marked [ComVisible]
Error model Map to HRESULT + short message Throwing rich .NET exceptions across the boundary unchecked
Data shapes Primitives, arrays, simple DTOs Deep object graphs, callbacks you cannot lifetime-manage
Threading Document STA/MTA requirements in README Assuming thread-pool = fine
Testing Smoke host that activates ProgID in CI (Windows runner) “It compiled” as the only check

This is the same instinct I use on integration microservices: keep the dirty protocol at the edge, keep the core boring. COM is just an older protocol.

Pitfalls that show up in tickets

  • “Class not registered” — wrong bitness, wrong user hive (per-user vs machine), or installer skipped regasm/native registration.
  • RPC_E_WRONG_THREAD / mysterious hangs — apartment mismatch; fix the thread model, do not spray Task.Run randomly.
  • Memory growth in long-running hosts — RCWs not released; event sinks still subscribed; Excel quit but child objects alive.
  • Versioning breaks — changed method order on a dual interface without a new IID.
  • Security — elevating a process just so COM activation works. Fix registration and identity instead.
  • Cloud hosts — classic COM servers often need a full Windows desktop session. Do not put Office automation on a locked-down Server Core container and expect stability.

FAQ: COM components in C#

What are COM components in C# used for today?

Mostly boundaries: calling vendor-native APIs, plugin hosts, and some Office/desktop automation. Greenfield distributed systems should not invent new COM contracts between services.

What is the difference between RCW and CCW?

RCW lets managed code call a COM object. CCW lets COM callers invoke a managed object. Both are proxies with lifetime rules you must respect.

How do I create COM components in C#?

Mark interfaces/classes [ComVisible(true)] with explicit GUIDs, use ClassInterfaceType.None, assign a ProgID, and register (or ship a reg-free manifest). Keep the COM-visible surface thin and push logic into normal .NET libraries.

Why does FinalReleaseComObject matter?

Because the GC finalizer timing is not a resource plan. In servers and add-ins that create many COM objects, deterministic release prevents handle and memory growth.

Can I use COM components C# interop on .NET 6/8?

Yes for many Windows scenarios, but the hosting and registration story is not identical to old .NET Framework desktop defaults. Read the current Microsoft expose-to-COM docs for your TFM before copying a 2012 blog post’s regasm steps blindly.

Is COM the same as C++/CLI or P/Invoke?

No. P/Invoke targets C exports. C++/CLI is a mixed-language bridge. COM is an interface/activation model with IUnknown and (usually) registration. Pick the thinnest bridge that matches the native surface you must call.

Closing

If you search for com components c#, you usually need one of three answers: how to call a legacy server safely, how to expose a small .NET surface to a COM host, or how to stop the process leaking. The pattern is the same every time — treat COM as an edge adapter, own lifetime and threading explicitly, and keep domain code free of [ComVisible] attributes.

Related reading on this blog: Azure Service Bus peek-lock (another boundary that punishes sloppy lifetime), and the integration series starting at microservices integration patterns on Azure.

Similar Posts