Why a C++/CLI Wrapper Is Often the Right Way to Call Native DLLs from C# - Compared with P/Invoke

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

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

In practice, though, the DLLs you actually run into are quirkier. They have C++ classes, ownership conventions, exceptions that fly around, and std::wstring and std::vector showing up as a matter of course. If you push everything through P/Invoke from there, the boundary tends to get harder and harder to live with.

This article walks through what gets easier when you slip a thin C++/CLI wrapper in between for those situations. This is not a “P/Invoke is bad” article. It is about how the situations where P/Invoke is enough and the situations where C++/CLI helps are simply different.

1. Short version

  • if the other side is a set of C functions, P/Invoke is the natural fit
  • if the other side is a C++ library, a thin C++/CLI wrapper tends to be easier to maintain
  • especially when classes, ownership, strings, arrays, exceptions, or callbacks are involved, do not force C# to deal with them directly

In short, do not drag the native DLL’s quirks straight into C#. Let the C++ side absorb the native side, and only show .NET a clean face. When that division of labor works, the code and the debugging both get a lot calmer.

2. When P/Invoke is enough

The important thing to say first: if P/Invoke gets the job done, that is the simplest answer. There is no need to drag C++/CLI in for its own sake.

P/Invoke is a good fit for things like:

  • a flat function-style API exposed with extern "C"
  • arguments and return values that are integers, pointers, or simple structs
  • clear string conventions and simple buffer ownership
  • resource management that follows an obvious Create / Destroy shape
  • code that translates cleanly into SafeHandle and StructLayout on the C# side

If the API is that tidy, you just declare it on the C# side and use it. It feels a lot like calling a Windows API, and the code is easy to read.

3. Where P/Invoke suddenly gets painful

The trouble starts when the other side is not “just a C API.” From there, the air changes quickly.

3.1. When you start dealing with C++ classes

If the native DLL is designed around C++ classes, what C# really wants to see are methods, but P/Invoke can only deal with DLL exported functions directly. In other words, sooner or later you need a layer that flattens things into C-style functions.

At that point, what you are doing is essentially “writing a wrapper.” If that is the case, instead of growing a forest of IntPtr values and release functions on the C# side, it is more natural to put the wrapper on the C++ side.

3.2. When ownership and lifetime are hard to see

In C++, you regularly run into questions like:

  • does the caller free it, or the callee?
  • is the returned pointer borrowed?
  • is it const& or a transfer of ownership?
  • is something cached internally, with assumptions about lifetime?

If you try to express all of this through C# IntPtr values, it might work the first time around, but it gets rough when you have to read the code again later. The “who deletes this pointer, and when?” question is exactly the kind of thing that muddies the boundary fast.

3.3. When std::wstring, std::vector, callbacks, and exceptions show up

This is where P/Invoke moves into “yes you can write it, but you will not enjoy it” territory.

  • you want to express std::wstring directly from C#
  • you want to return a std::vector<T>
  • you want to receive native progress through a callback
  • C++ exceptions get thrown on failure

As these pieces pile up, the C# side gets more MarshalAs, more manual buffers, fixed-size arrays, delegate lifetime management, error-code interpretation, and so on.

You can grind through it if you really want to. The painful part is that none of that effort is the actual point. What you really wanted to spend time on is business logic or UI, not wrestling with the boundary.

3.4. When you do not want C++ concerns leaking into C#

The native DLL’s API is not necessarily shaped the way C# wants to consume it.

For example, on the native side it might be designed so that:

  • a single operation is split across several method calls
  • errors come back through return values and out-arguments
  • there are assumptions about initialization order
  • there are constraints on thread safety

Even so, you usually want the C# side to see a more straightforward API. C++/CLI is a very convenient layer for that translation work.

4. The shape with a C++/CLI wrapper in between

The layout itself is simple.

flowchart LR
    Cs[C# app] -->|.NET-style API| Wrapper[C++/CLI wrapper DLL]
    Wrapper -->|works with native headers and types directly| Native[Native C++ DLL]

What C# sees is a .NET-style API only, and you keep things like:

  • string conversion
  • array and vector conversion
  • exception conversion
  • ownership cleanup
  • error code interpretation
  • thread-boundary or callback adaptation, when needed

inside the C++/CLI side.

The key thing is to not let the C++/CLI project itself grow too big. Its job is “translation” and “shaping,” full stop. Once business logic starts moving in there, that layer ends up taking center stage, which is not what you wanted.

5. What actually gets easier with C++/CLI

5.1. you can keep C++ types as C++ types

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

Meaning: you do not have to “reproduce the C++ world” on the C# side. std::wstring and std::vector get received as C++ types first, and then handed off to .NET in whatever shape makes sense.

5.2. you can shape the API for .NET

On the C# side, you can hand out APIs that use familiar shapes:

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

This difference looks small but changes the caller’s experience a lot. Especially in team settings, it really helps that members who do not know the native side can still use the API comfortably.

5.3. you can sort out exception and error responsibility

When the native side mixes exceptions and error codes, taking that straight into C# is awkward. The C++/CLI side can consolidate things and:

  • convert exceptions into .NET exceptions
  • convert error codes into meaningful exceptions or result types
  • attach the context the logs need

Translating things into “meaningful failures” once at the boundary makes the calling code much cleaner.

5.4. you can hide ABI wobble from C#

C++ classes and methods do not have a simple ABI like C functions do. If C# starts to know about that directly, the realities of exported functions and marshaling start showing through.

With a C++/CLI wrapper in between, you can keep C++ concerns on the C++ side and only show C# a stable surface. This separation also pays off when the underlying library gets updated.

5.5. it makes incremental migration easier

Rewriting an entire native DLL all at once is heavy. With a C++/CLI wrapper, you can wrap just the APIs you need with a thin layer, and start using them from new C# screens or workflows. Migration in steps becomes a lot more practical.

When you want to keep using existing Windows assets while pulling the surrounding code toward .NET, the fit is very good.

6. Code excerpts

What follows are not “complete runnable samples” — just enough to give you a feel for the shape of the boundary.

6.1. What the native DLL 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);
    };
}

This API is perfectly normal C++. Calling it directly from C#, on the other hand, takes some work.

6.2. What it ends up looking like with P/Invoke

First, to call this directly from C#, you have to flatten it into C-style functions somewhere. That usually means writing bridge functions like this on the side:

// What a C-API bridge tends to look like
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 with this kind of feel:

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 that were the end of it, fine — but in real life you also have to deal with:

  • how to return variable-length data
  • who frees the string buffers
  • where to put the error details
  • how to keep callback lifetimes safe

In other words, what started as “let’s just use P/Invoke” has effectively become “let’s design a C-compatible API”, more often than not.

6.3. How this looks with a C++/CLI wrapper

On the C++/CLI side, you absorb the native concerns and shape the API that C# will see.

// 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;
};
// 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)
{
    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 much more straightforward.

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 IntPtr values, the release functions, and the native string buffer concerns are not visible. That is the part that matters.

7. When you should still not pick C++/CLI

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

  • the other side already exposes a clean C API
    • in this case P/Invoke is the more natural choice.
  • you need cross-platform support
    • C++/CLI is Windows-only.
  • the boundary is small and the types are simple
    • the overhead of adding a wrapper DLL can be the bigger cost.
  • you have strict AOT or distribution constraints
    • look at the overall configuration requirements first.

So the criterion is: “given the complexity of the native DLL, where is the most natural place to do the translation?” Simple - P/Invoke. Complex - C++/CLI. That split tends to land in the right spot most of the time.

8. Wrap-up

P/Invoke is still the standard way to call native DLLs from C#. But that holds up when the other side is shaped like a clean C API.

If the native side is designed as a C++ library, lining up IntPtr values and marshaling attributes on the C# side and grinding through it is often less effective than building a thin wrapper in C++/CLI to keep the boundary clean.

In particular, when:

  • class-based APIs
  • ownership assumptions
  • std::wstring or std::vector
  • exception conversion
  • callbacks
  • incremental migration

are in play, C++/CLI is a very realistic option.

The work itself is not flashy. But “where do we tidy up the boundary?” is exactly the kind of question that pays off in maintainability later. When you want to keep using existing Windows assets while bringing .NET into the picture, C++/CLI is still a useful tool.

9. References

Related Articles

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

Related Topics

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

Where This Topic Connects

This article connects naturally to the following service pages.

Back to the Blog