Calling Native DLLs from C#: C++/CLI Wrapper vs P/Invoke

· Updated: · · C++/CLI, C#, Windows Development, Native Interop

Revision history (1 updates, last updated Sep 1, 2026)

A log of the changes made to this article. Where a pre-update version was archived, it stays readable at a permanent DOI link.

Retranslated as a full translation of the Japanese original. The previous English version was an abridgement that carried only part of the source, so sections, tables, Mermaid diagrams, figure captions and FAQ entries were missing. All of them have been restored to match the Japanese original, and the technical claims are the same as in the Japanese version. Read the version before this update (DOI: 10.5281/zenodo.21614455)
First published
Cite this article(DOI: 10.5281/zenodo.21614454)

This article is archived on Zenodo. Below are both the DOI that always resolves to the latest version and the DOI pinned to the version you are reading.

Go Komura (2026). Calling Native DLLs from C#: C++/CLI Wrapper vs P/Invoke. KomuraSoft LLC. https://doi.org/10.5281/zenodo.21614454 https://comcomponent.com/en/blog/2026/03/07/000-cpp-cli-wrapper-for-native-dlls/

DOI (latest version)
10.5281/zenodo.21614454
DOI (this version)
10.5281/zenodo.22217119

Wanting to use existing Windows assets or existing DLLs from C# is a very common requirement. If the other side is a straightforward C interface like the Win32 API, P/Invoke is enough.

But what shows up in real work is quirkier DLLs. There are C++ classes, ownership conventions, exceptions flying around, and std::wstring and std::vector appearing as a matter of course. If you try to push through with P/Invoke alone here, the boundary layer usually gets more and more painful.

In this article, we look at what becomes easier when you insert one thin C++/CLI wrapper in such cases. This is not an argument that P/Invoke is bad - the point is that the cases where P/Invoke is sufficient and the cases where C++/CLI pays off are different.

The code excerpts in this article are published on GitHub as a complete buildable sample set (a native C++ library, a C API bridge, a C++/CLI wrapper, and C# consumer code for both the P/Invoke and C++/CLI versions).

cpp-cli-wrapper-for-native-dlls - komurasoft-blog-samples (GitHub)

Who This Article Is For, and What It Assumes

This is written for developers who have called a native DLL from C# and can write a DllImport declaration, but who get stuck the moment the other side becomes a C++ class library. No prior C++/CLI experience is needed. Conversely, if you have never written P/Invoke, it is faster to read “Safely Calling Win32 APIs from C# - A Practical P/Invoke Guide” first.

The assumed environment is Windows plus Visual Studio 2022, in a setup where the C++/CLI wrapper (.vcxproj) and the C# project can live in the same solution. The target can be .NET Framework or .NET 8 or similar, but the .NET side has constraints of its own, which are collected in chapter 7.

Terms to Know Up Front

Term Meaning
P/Invoke (Platform Invoke) The mechanism where C# declares an exported function of a native DLL with the DllImport / LibraryImport attribute and calls it directly
Marshaling Converting between .NET types (string, arrays, and so on) and native representations (wchar_t*, raw pointers, and so on) in both directions at the boundary
ABI (Application Binary Interface) The agreements that let compiled binaries fit together, such as the calling convention, how arguments are passed, struct memory layout, and name mangling. The agreements for C functions are simple and stable, but for C++ classes the name mangling and vtable layout are compiler-dependent, so C# cannot rely on them directly (5.4)
SafeHandle The .NET abstract class that wraps a native handle. It is used instead of passing a bare IntPtr around, to prevent leaked handles and use-after-free, where a handle is released while it is still in use (6.2)
StructLayout The C# attribute that makes a struct’s memory layout match the native side. It is used to lay fields out in declaration order with LayoutKind.Sequential, to specify string handling with CharSet, and so on (6.2)
marshal_as The conversion helper provided by C++/CLI. It converts between .NET types and native types in both directions, as in marshal_as<std::wstring>(managedString). You include headers such as msclr/marshal_cppstd.h to use it (6.3)
Mixed assembly A DLL that contains both native machine instructions and MSIL. A C++/CLI wrapper is one of these (chapter 7)

Table of Contents

  1. The Conclusion First (In One Line)
  2. Cases Where P/Invoke Is Sufficient
  3. The Boundary Where P/Invoke Suddenly Gets Painful
  4. The Architecture With a C++/CLI Wrapper
  5. What C++/CLI Makes Easier
  6. Code Excerpts
  7. Cases Where You Still Should Not Choose C++/CLI
  8. Conclusion
  9. References

Knowledge map for this article

This article explains that when calling a native DLL from C#, P/Invoke is straightforward if the target is a flat set of extern C functions, but that when the library is a class-centric C++ library in which ownership, strings, exceptions, and callbacks are involved, inserting a thin wrapper written in C++/CLI is easier to maintain. With P/Invoke you represent native types with SafeHandle and StructLayout yet still tend to end up hand-writing a C-style bridge layer, whereas C++/CLI confines type conversion through marshal_as, the Dispose/Finalize pattern implemented with a destructor and a finalizer, and the translation of exceptions into .NET exceptions to the C++ side, so that only a stable API is exposed to C#. On the other hand, C++/CLI is Windows-only and incompatible with Native AOT, and it brings a dependency on /clr compilation and on ijwhost.dll, so it cannot be chosen when cross-platform support is required or distribution constraints are strict.

Choosing between a C++/CLI wrapper and P/InvokeDiagram showing how to choose between P/Invoke and a C++/CLI wrapper according to the complexity of the native DLL, and how marshaling, ownership, exceptions, callbacks, and distribution constraints differ between the two.requiresrecommended forrecommended fornot recommended fornot recommended forusesimplementsusesusesimplementsrequiresimplementsrequiresrequiresincompatible withrequiresconfigured byimplementsrequiressuccessor toincompatible withC++/CLIP/InvokeC API Bridge LayerFlat C APIClass-based native C++ libraryMarshalingmarshal_asSafeHandleStructLayoutDispose/Finalize Pattern (C++/CLI)Ownership and Lifetime ManagementException Translation at Layer BoundariesCallback Delegate Lifetime ManagementNative AOTijwhost.dll/clr Compiler OptionMixed AssemblyCross-platform requirement

In the diagram a solid line marks a relation that always holds and a dashed line marks a conditional one (the conditions are given per relation on the detail page). The full list of relations (21 in total, with evidence and certainty) and the definitions of the main concepts are collected on the knowledge map detail page (in Japanese). Data: JSON-LD / Turtle

1. The Conclusion First (In One Line)

  • If the other side is a set of C functions, P/Invoke is the natural choice
  • If the other side is a C++ library, inserting one C++/CLI wrapper makes it easier to maintain
  • Especially when classes, ownership, strings, arrays, exceptions, and callbacks are involved, it is better not to make the C# side strain itself

In short: do not bring the native DLL’s concerns directly into C#. Absorb the native concerns on the C++ side, and present only a polished surface to .NET. When this division of labor works, both the code and the debugging get much less painful.

Choosing based on the shape of the DLL you are callingShows the conclusion of this article as a branch where a set of C functions leads to P/Invoke and a C++ library leads to inserting one C++/CLI wrapper for easier maintenance.A set of C functionsA C++ libraryWhich is the DLL you are callingP/Invoke is the natural choiceInsert a C++/CLI wrapperNative concerns are absorbed on the C++ side

Figure 1: The conclusion on choosing between the two. A set of C functions means P/Invoke, a C++ library means a C++/CLI wrapper.

2. Cases Where P/Invoke Is Sufficient

If P/Invoke gets the job done, it is the simplest option. There is no need to force C++/CLI in.

P/Invoke is well-suited to cases like these.

  • The API is a flat set of functions exposed via extern "C"
  • Arguments and return values are integers, pointers, simple structs, and so on
  • The string conventions are clear and buffer responsibilities are simple
  • Resource management is easy to follow, like Create / Destroy
  • You can write SafeHandle and StructLayout naturally on the C# side

If things are this tidy, you just declare and call from C#, and since it feels close to calling the Windows API, the implementation stays readable too.

Conditions that make P/Invoke sufficientShows that when a flat C API, simple arguments and return values, and clear string and resource conventions are all in place, all you need to do is declare and call from the C# side.Flat function API with extern CP/Invoke is enoughSimple arguments and return valuesClear string and resource conventionsJust declare and call from C#

Figure 2: If the API you are calling is this tidy, there is no need to force C++/CLI in.

3. The Boundary Where P/Invoke Suddenly Gets Painful

The problem is when the other side is not “just a C API.” From here the picture changes sharply.

3.1. When You Start Dealing With C++ Classes

If the native DLL is designed around C++ classes, you really want to call the class methods directly - but what P/Invoke can target is the DLL’s exported functions. That means somewhere along the line you need a layer that flattens things into C-style functions.

At that point, what you are doing is essentially “writing a wrapper.” And if so, rather than sprouting piles of IntPtr and free functions on the C# side, moving the wrapper to the C++ side is more natural.

What happens when you pick P/Invoke against C++ classesShows the path where you want to call C++ class methods but P/Invoke can only target exported functions, so a layer that flattens things into C style is needed and what you are doing is essentially writing a wrapper.Want to call C++ class methodsOnly exported functions can be calledA layer that flattens into C style functions is neededWhat you are doing is essentially a wrapperThen moving it to the C++ side is more natural

Figure 3: Even if you try to push through with P/Invoke, a C++ class on the other side means you end up writing a wrapper somewhere anyway.

3.2. When Ownership and Lifetime Management Are Hard to See

In C++, questions like these are entirely routine:

  • Does the caller free it?
  • Is the returned pointer borrowed?
  • Is it a const& or an ownership transfer?
  • Is something cached internally with lifetime assumptions?

If you express this with IntPtr on the C# side, it may work at first, but it is quite painful to read back later. Once the “wait, who frees this pointer and when?” problem begins, the boundary layer turns murky fast.

How ownership assumptions turn murky when expressed with IntPtrShows that expressing C++ side concerns such as who frees the memory, whether a pointer is borrowed or transferred, and whether lifetime assumptions exist through the C# IntPtr makes the code painful to read back later and turns the boundary murky.Who frees itExpress it with C# IntPtrBorrowed or ownership transferAre there lifetime assumptionsWorks at first but unreadable laterThe boundary turns murky fast

Figure 4: Carrying ownership and lifetime assumptions around as IntPtr values leaves the boundary murky, stuck on the question of who frees this pointer and when.

3.3. When std::wstring, std::vector, Callbacks, and Exceptions Appear

Around this point, P/Invoke enters the territory of “you can write it, but it is no fun.”

  • You want to represent std::wstring directly from C#
  • You want to return a std::vector<T>
  • You want to receive native progress via callbacks
  • C++ exceptions are thrown on failure

As these elements pile up, the C# side accumulates MarshalAs, manual buffers, fixed-length arrays, delegate lifetime management, error-code interpretation, and more.

Of course, you can write it all if you try hard enough. The painful part is that the effort is not where the substance is. What you actually want to build is business logic or UI, not a martial art of boundary marshaling.

More C++ elements mean more burden on the C# sideShows that as elements such as returning wstring or vector, receiving progress through callbacks, and C++ exceptions being thrown pile up, MarshalAs, manual buffers, and delegate lifetime management accumulate on the C# side.Want to return wstring or vectorBoundary code piles up on the C# sideWant progress through callbacksC++ exceptions thrown on failureMarshalAs and manual buffersDelegate lifetime management and error interpretation

Figure 5: Every time another C++ flavored element appears, the burden of boundary code on the C# side grows.

3.4. When You Do Not Want C++ Concerns Leaking Into C#

The native DLL’s API is not necessarily shaped for C# as is.

For example, even if the native side is designed so that:

  • Several method calls are combined into one logical operation
  • Errors are returned via return values and out parameters
  • There are assumptions about initialization order
  • There are constraints on thread safety

you usually want to show the C# side a more straightforward API. As the layer that performs this conversion, C++/CLI is remarkably convenient.

A layer that converts native concerns before C# sees themShows the division of labor where design concerns of the native API such as initialization order and thread safety constraints are absorbed by the C++/CLI conversion layer so that C# is shown a more straightforward API.Design concerns of the native APIInitialization order and thread constraintsC++/CLI conversion layerShow C# a straightforward API

Figure 6: Rather than pointing the native design concerns straight at C#, insert C++/CLI as the converting layer.

4. The Architecture With a C++/CLI Wrapper

The architecture is simple.

API designed for .NETWorks directly with native headers and typesC# appC++/CLI wrapper DLLNative C++ DLL

Figure 7: The architecture that inserts one C++/CLI wrapper DLL between the C# app and the native C++ DLL.

Make sure that all C# sees is a .NET-flavored API, and confine the following to the C++/CLI side:

  • String conversion
  • Array and vector conversion
  • Exception conversion
  • Ownership cleanup
  • Error-code interpretation
  • If needed, absorbing thread boundaries and callbacks

The important thing is to avoid letting the C++/CLI project itself grow too large. Its role is strictly “translation” and “shaping.” If business logic starts creeping in, that layer ends up being where the real work lives instead.

Work confined to the C++/CLI wrapperShows the boundary of responsibility where string and array conversion, exception and error code conversion, and ownership cleanup are confined to the C++/CLI side while business logic does not go in.Role of the C++/CLI wrapperString and array conversionException and error code conversionOwnership cleanupNo business logic

Figure 8: Keep the wrapper’s role limited to translation and shaping, and do not let it grow too large.

5. What C++/CLI Makes Easier

5.1. You Can Handle C++ Types as C++ Types

This is a big one. On the C++/CLI side you can include the native headers and use the C++ types directly.

In other words, the C# side no longer has to forcibly “recreate the C++ world.” std::wstring and std::vector can be received as C++ types first, then handed to the .NET side in whatever form is needed.

Receiving C++ types first and then handing them to .NETShows that including the native headers lets wstring and vector be received as C++ types and converted into the required form before being handed to the .NET side, so the C# side never has to recreate the C++ world.Native wstring and vectorReceive as C++ types on the C++/CLI sideConvert into the required formHand to the .NET sideNo recreating the C++ world in C#

Figure 9: Receive C++ types as C++ types first, then convert them before handing them to .NET.

5.2. You Can Shape the API for .NET

To the C# side you can expose the API in familiar forms:

  • string
  • byte[]
  • List<T>
  • IDisposable
  • Exceptions

This difference looks modest but greatly changes the burden on consumers. Especially in team development, it pays off that members unfamiliar with native internals can still work with it comfortably.

5.3. Exception and Error Responsibilities Are Easier to Organize

When the native side mixes exceptions and error codes, receiving them raw on the C# side is unwieldy. On the C++/CLI side you can consolidate once:

  • Convert exceptions into .NET exceptions
  • Convert error codes into meaningful exceptions or result types
  • Add the context needed for logging

If you translate failures into “meaningful failures” once at the boundary, the calling side becomes much cleaner.

Translating exceptions and error codes at the boundaryShows that exceptions and error codes mixed on the native side are consolidated once on the C++/CLI side, exceptions are converted into .NET exceptions, error codes are converted into meaningful forms, and the context needed for logging is added.Native exceptions and error codesConsolidate once on the C++/CLI sideConvert exceptions into .NET exceptionsConvert error codes into meaningful formsAdd the context needed for logging

Figure 10: Translating failures into meaningful failures once at the boundary keeps the C# calling side clean.

5.4. You Can Hide ABI Instability From C#

C++ classes and methods do not have a simple ABI the way C functions do. Once C# starts knowing about those details directly, exported-function and marshaling concerns surface in your code.

With a C++/CLI wrapper in between, C++ concerns stay confined to the C++ side, and C# sees only a stable surface. This separation also pays off when the library is updated.

Blocking ABI instability with a wrapperShows that because C++ classes and methods do not have an ABI as simple as C functions, confining those concerns to the C++ side and showing C# only a stable surface gives a separation that also pays off when the library is updated.The ABI of C++ classes is not simpleConfine C++ concerns to the C++ sideShow C# only a stable surfaceSeparation that pays off on library updates

Figure 11: Keep marshaling and exported-function concerns off the surface, and show C# only a stable face.

5.5. Incremental Migration Is Easier

Rewriting an entire existing native DLL all at once is heavy. With a C++/CLI wrapper, you can wrap only the APIs you need, thinly, and start using them from new C# screens and workflows - an incremental migration.

For scenarios where you want to keep existing Windows assets alive while moving the periphery to .NET, the fit is excellent.

6. Code Excerpts

Rather than a “complete sample that runs as is,” here are just enough excerpts to convey what the boundary looks like.

6.1. What the Native DLL’s API Looks Like

// NativeLib.hpp
#pragma once
#include <string>
#include <vector>

namespace NativeLib
{
    struct AnalyzeOptions
    {
        int threshold;
        std::wstring modelPath;
    };

    struct AnalyzeResult
    {
        bool ok;
        std::wstring message;
        std::vector<int> scores;
    };

    class Analyzer
    {
    public:
        explicit Analyzer(const std::wstring& licensePath);
        AnalyzeResult Analyze(const std::wstring& imagePath, const AnalyzeOptions& options);
    };
}

As native C++ goes, this API is ordinary. But touching it directly from C# takes real effort.

6.2. What It Becomes If You Try P/Invoke

First, to call it directly from C#, it has to be flattened into C-style functions somewhere. You end up preparing bridge functions like these.

// Sketch of a bridge flattened into a C API
extern "C"
{
    __declspec(dllexport) void* Analyzer_Create(const wchar_t* licensePath);
    __declspec(dllexport) void  Analyzer_Destroy(void* handle);

    __declspec(dllexport) int Analyzer_Analyze(
        void* handle,
        const wchar_t* imagePath,
        const AnalyzeOptionsNative* options,
        AnalyzeResultNative* result);
}

The C# side ends up looking something like this.

internal sealed class SafeAnalyzerHandle : SafeHandle
{
    private SafeAnalyzerHandle() : base(IntPtr.Zero, ownsHandle: true) { }

    public override bool IsInvalid => handle == IntPtr.Zero;

    protected override bool ReleaseHandle()
    {
        NativeMethods.Analyzer_Destroy(handle);
        return true;
    }
}

[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
internal struct AnalyzeOptionsNative
{
    public int Threshold;
    public IntPtr ModelPath;
}

internal static class NativeMethods
{
    [DllImport("NativeBridge.dll", CharSet = CharSet.Unicode)]
    internal static extern SafeAnalyzerHandle Analyzer_Create(string licensePath);

    [DllImport("NativeBridge.dll", CharSet = CharSet.Unicode)]
    internal static extern void Analyzer_Destroy(IntPtr handle);

    [DllImport("NativeBridge.dll", CharSet = CharSet.Unicode)]
    internal static extern int Analyzer_Analyze(
        SafeAnalyzerHandle handle,
        string imagePath,
        ref AnalyzeOptionsNative options,
        out AnalyzeResultNative result);
}

If it ended there, fine - but in practice more questions keep coming:

  • How do you return variable-length data?
  • Who frees the string buffers?
  • Where do you put error details?
  • How do you protect callback lifetimes?

In other words, you thought you chose P/Invoke, but you have effectively started designing a C-compatible API.

How a P/Invoke plan turns into designing a C compatible APIShows the path where even though you intend to call directly with P/Invoke, you prepare separate C bridge functions, write SafeHandle and StructLayout on the C# side, face questions of variable length data and freeing and callbacks, and have effectively started designing a C compatible API.Intend to call directly with P/InvokePrepare separate C bridge functionsWrite SafeHandle and StructLayoutQuestions of variable length, freeing, callbacksDesigning a C compatible API begins

Figure 12: What felt like simply choosing P/Invoke often turns out to be designing a C-compatible API.

Moving to C++/CLI replaces these questions as follows. They do not vanish by magic - the accurate way to put it is that they move into a form that is natural to write on the C++ side.

Question that comes up with P/Invoke Typical answer on the P/Invoke side What it becomes on the C++/CLI side
How do you return variable-length data? Provide a two-step pair of “a function that reports the required size” and “a function that fills the buffer,” and allocate the buffer on the C# side Receive the std::vector the native side returns as is, and copy it into a List<int> or an array to return (6.3)
Who frees the string buffers? Add a free function to the C API and honor the convention that C# always calls it The lifetime of the std::wstring is contained on the native side, and C# simply gets a newly created String^ (6.3)
Where do you put error details? On top of the error code in the return value, provide a function that retrieves the details or an out parameter struct Catch the native exception with try / catch, convert it into a meaningful .NET exception, and rethrow (6.3)
How do you protect callback lifetimes? Hold a reference in a field or similar so the delegate is not collected by the GC Confine registering and unregistering the callback to the C++ side, and show C# only an event or a delegate
How do you express handle ownership? Derive from SafeHandle and call the free function from ReleaseHandle delete the native object in the wrapper’s destructor or finalizer (6.3)

6.3. How It Reads With a C++/CLI Wrapper

On the C++/CLI side, you absorb the native concerns and shape the API shown to C#.

// AnalyzerWrapper.h
#pragma once
#include "NativeLib.hpp"

using namespace System;
using namespace System::Collections::Generic;

public ref class AnalysisOptions
{
public:
    property int Threshold;
    property String^ ModelPath;
};

public ref class AnalysisResult
{
public:
    property bool Ok;
    property String^ Message;
    property List<int>^ Scores;
};

public ref class AnalyzerWrapper : IDisposable
{
public:
    AnalyzerWrapper(String^ licensePath);
    ~AnalyzerWrapper();
    !AnalyzerWrapper();

    AnalysisResult^ Analyze(String^ imagePath, AnalysisOptions^ options);

private:
    NativeLib::Analyzer* _native;
};

The two members specific to C++/CLI here are ~AnalyzerWrapper() and !AnalyzerWrapper(). Both look like C++ destructors, but their roles map onto the .NET Dispose pattern.

How you write it in C++/CLI What the compiler generates Behavior as seen from C#
~AnalyzerWrapper() (destructor) Dispose(), which implements IDisposable Runs when a using block is exited, or when Dispose() is called
!AnalyzerWrapper() (finalizer) Finalize(), which overrides Object::Finalize Runs when the GC collects the object. When it runs is not determined

The standard approach is to write the release of native resources in the finalizer and call it from the destructor. That is exactly what the implementation below does, where ~AnalyzerWrapper() does nothing but call this->!AnalyzerWrapper(); written this way, even if the C# side forgets to call Dispose(), the GC picks it up in the end. When the destructor does run, GC::SuppressFinalize suppresses finalization, so there is no double free.

Note that Dispose(), Finalize(), and Dispose(bool) are generated by the compiler, so you do not write them yourself in C++/CLI. Conversely, C++/CLI code cannot call Dispose() directly; it invokes the destructor with the delete operator. This correspondence is summarized under “Destructors and finalizers” in How to: Define and consume classes and structs (C++/CLI) - Microsoft Learn.

Division of labor between the destructor and the finalizerShows that a using block or Dispose in C# calls the destructor, which releases the native resource through the finalizer, that a forgotten Dispose still lets the GC call the finalizer so the object is picked up in the end, and that SuppressFinalize prevents a double free.C# using or DisposeDestructor (equivalent to Dispose)Forgetting to call DisposeFinalizer at GC collectionCall the finalizerdelete the native resourceSuppressFinalize prevents a double free

Figure 13: The standard approach of writing the release in the finalizer and calling it from the destructor. Even a forgotten Dispose is picked up by the GC in the end.

// AnalyzerWrapper.cpp
#include "AnalyzerWrapper.h"
#include <msclr/marshal_cppstd.h>

using msclr::interop::marshal_as;

AnalyzerWrapper::AnalyzerWrapper(String^ licensePath)
{
    _native = new NativeLib::Analyzer(marshal_as<std::wstring>(licensePath));
}

AnalyzerWrapper::~AnalyzerWrapper()
{
    this->!AnalyzerWrapper();
}

AnalyzerWrapper::!AnalyzerWrapper()
{
    delete _native;
    _native = nullptr;
}

AnalysisResult^ AnalyzerWrapper::Analyze(String^ imagePath, AnalysisOptions^ options)
{
    // If this is called after disposal, stop here before entering native code.
    // The destructor (= Dispose) sets _native to nullptr, so without this check
    // the call goes into native code through a null pointer and takes the whole
    // process down with an access violation instead of raising a .NET exception.
    // From the C# side, the expected behavior is an ObjectDisposedException when
    // the object is touched after Dispose, and every method that uses _native needs it
    if (_native == nullptr)
    {
        throw gcnew ObjectDisposedException("AnalyzerWrapper");
    }

    NativeLib::AnalyzeOptions nativeOptions{};
    nativeOptions.threshold = options->Threshold;
    nativeOptions.modelPath = marshal_as<std::wstring>(options->ModelPath);

    try
    {
        auto nativeResult = _native->Analyze(
            marshal_as<std::wstring>(imagePath),
            nativeOptions);

        auto managed = gcnew AnalysisResult();
        managed->Ok = nativeResult.ok;
        managed->Message = gcnew String(nativeResult.message.c_str());
        managed->Scores = gcnew List<int>();

        for (int score : nativeResult.scores)
        {
            managed->Scores->Add(score);
        }

        return managed;
    }
    catch (const std::exception& ex)
    {
        throw gcnew InvalidOperationException(gcnew String(ex.what()));
    }
}

The C# side becomes remarkably plain.

using var analyzer = new AnalyzerWrapper(@"C:\license.dat");

var result = analyzer.Analyze(
    @"C:\input.png",
    new AnalysisOptions
    {
        Threshold = 80,
        ModelPath = @"C:\model.bin"
    });

if (!result.Ok)
{
    Console.WriteLine(result.Message);
}

What C# sees is string, List<int>, and IDisposable. The concerns of IntPtr, free functions, and native string buffers are invisible. That is what matters.

7. Cases Where You Still Should Not Choose C++/CLI

Of course, C++/CLI is not a silver bullet. There are situations where it should not be chosen.

  • The other side already exposes a clean C API
    • In that case, P/Invoke is the more natural choice.
  • You need cross-platform support
    • C++/CLI assumes Windows.
  • The boundary surface is small and the types are simple
    • The cost of adding another wrapper DLL can outweigh the benefit.
  • You are looking very strictly at AOT or distribution constraints
    • Better to review the requirements of the overall configuration first.

Only that last item, “AOT or distribution constraints,” is abstract, so here are the constraints that actually bite. This is an area where carrying over the instincts of the .NET Framework era makes it easy to trip.

Constraint Details Impact in practice
OS C++/CLI targeting .NET (the .NET Core line) is Windows only If there is any plan to run on Linux containers or macOS, it is already ruled out
Native AOT C++/CLI is explicitly listed as unsupported by Native AOT. Along with it, dynamic loading such as Assembly.LoadFile, System.Reflection.Emit, and Windows built-in COM are unavailable too It is incompatible with a plan to produce a single native binary with PublishAot
Output format When targeting .NET, only a DLL is possible, not an exe. .NET Standard cannot be targeted either Put the entry point in the C# exe and reference the C++/CLI project as a DLL
Project format You use a .vcxproj, not an SDK-style csproj. A single project also cannot multi-target several .NET versions If you need both a .NET Framework build and a .NET build, split the project files
Runtime dependencies Specifying /clr also enables /MD, so the MSVC runtime DLLs are required. When targeting .NET, ijwhost.dll must additionally be placed in the output If you are counting on XCOPY deployment or single-file publishing, check this first
CPU architecture Because a mixed assembly contains native machine instructions, one binary cannot cover every architecture the way C# AnyCPU does Build and ship per target, such as x86 and x64
How it is loaded From .NET 7 onward it is always loaded into the default AssemblyLoadContext. On .NET 6 and earlier, it can be loaded into a different AssemblyLoadContext when it is first called from the native side In setups that give each plugin its own load context, verify the behavior

Also note that a C++/CLI project can target .NET (the .NET Core line) only from Visual Studio 2019 onward. If all you have is an older environment, you will be planning around .NET Framework to begin with.

So the criterion is: “given the complexity of the native DLL, where is the most natural place to do the translation?” Simple means P/Invoke; complex means C++/CLI. This split works out well most of the time.

Situations where C++/CLI is not the better choiceShows the four situations of a clean C API already existing, cross platform support being required, a small boundary with simple types, and strict AOT or distribution constraints, in which a C++/CLI wrapper is not the better choice.A C API already existsDo not choose C++/CLICross platform support neededSmall boundary and simple typesStrict AOT and distribution constraintsWhere is translation most natural

Figure 14: C++/CLI is not a silver bullet. If any of these four apply, P/Invoke or a rethink of the architecture comes first.

8. Conclusion

As a way to use native DLLs from C#, P/Invoke remains the standard route. But that is true when the other side is well-behaved as a C API.

If the native side is designed as a C++ library, then rather than lining up IntPtr and marshaling attributes on the C# side and toughing it out, building a thin C++/CLI wrapper often keeps the boundary much cleaner.

In particular, when the following are involved:

  • Class-based APIs
  • Ownership assumptions
  • std::wstring and std::vector
  • Exception conversion
  • Callbacks
  • Incremental migration

C++/CLI is a very realistic option.

None of this is flashy work. But decisions like “where to tidy up the boundary” pay off squarely in later maintainability. When you want to make existing Windows assets and .NET thrive together, C++/CLI is still genuinely useful.

9. References

Recent articles sharing the same tags. Deepen your understanding with closely related topics.

These topic pages place the article in a broader service and decision context.

This article connects naturally to the following service pages.

Frequently Asked Questions

Common questions about the topic of this article.

How should I choose between P/Invoke and a C++/CLI wrapper?
If the other side is a flat set of C functions exposed via extern "C", P/Invoke is the straightforward and simplest option. If the other side is a C++ class-based library where ownership, strings, arrays, exceptions, and callbacks are involved, inserting one thin C++/CLI wrapper is easier to maintain. The criterion is where the translation is most natural given the complexity of the native DLL, and the split of simple means P/Invoke, complex means C++/CLI works out well most of the time.
What does inserting a C++/CLI wrapper make easier?
On the C++/CLI side you can include the native headers and handle std::wstring and std::vector as C++ types, so the C# side never has to recreate the C++ world. C# can be shown only .NET-flavored APIs such as string, byte[], List<T>, IDisposable, and exceptions, while IntPtr, free functions, and marshaling details stay hidden. C++ exceptions and error codes can be converted into .NET exceptions at the boundary, and incremental migration that keeps existing assets alive becomes easier as well.
When does going with P/Invoke alone become painful?
When the native DLL is designed around C++ classes, all P/Invoke can call directly are the DLL's exported functions, so you end up needing a layer that flattens them into C-style functions - which means you have effectively started designing a C-compatible API. Add wanting to return std::wstring or std::vector, wanting progress through callbacks, and C++ exceptions being thrown, and MarshalAs, manual buffers, and delegate lifetime management pile up on the C# side. Expressing ownership and lifetime assumptions in terms of IntPtr becomes quite painful to read back later.
Are there cases where I should not choose C++/CLI?
Yes. If the other side already exposes a clean C API, P/Invoke is the more natural choice. C++/CLI also assumes Windows, so it is unusable when you need cross-platform support. When the boundary surface is small and the types are simple, the cost of adding another wrapper DLL can outweigh the benefit, and if you are looking strictly at AOT or distribution constraints it is better to review the requirements of the overall configuration first.

Author Profile

Profile page for the article author.

Go Komura

Representative of KomuraSoft LLC

Focused on Windows software development, technical consulting, and investigations into failures that are difficult to reproduce.

Back to the Blog