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

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

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)

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

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 become considerably calmer.

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.

3. The Boundary Where P/Invoke Suddenly Gets Painful

The problem is when the other side is not “just a C API.” This is where the mood changes abruptly.

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.

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.

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

From here on, P/Invoke enters the territory of “writable, but not pleasant.”

  • 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.

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.

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

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 becomes the protagonist instead.

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.

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.

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.

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.

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;
};
// 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 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.

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. This split works out well most of the time.

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.

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