Why a C++/CLI Wrapper Is Often the Right Way to Call Native DLLs from C# - Compared with P/Invoke
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/Destroyshape - code that translates cleanly into
SafeHandleandStructLayouton 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::wstringdirectly 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:
stringbyte[]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::wstringorstd::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
- Mixed (Native and Managed) Assemblies - Microsoft Learn
- .NET programming with C++/CLI - Microsoft Learn
- Migrate C++/CLI projects to .NET - Microsoft Learn
- Using C++ Interop (Implicit PInvoke) - Microsoft Learn
- Platform Invoke (P/Invoke) - Microsoft Learn
- Overview of Marshaling in C++/CLI - Microsoft Learn
- marshal_as - Microsoft Learn
- Performance Considerations for Interop (C++) - Microsoft Learn
Related Articles
Recent articles sharing the same tags. Deepen your understanding with closely related topics.
How to Ship C# as a Native DLL with Native AOT - Calling UnmanagedCallersOnly Exports from C/C++
A practical guide to publishing a C# class library as a native DLL with Native AOT and calling it from C/C++ via UnmanagedCallersOnly — c...
Where Should Unit Tests End and Integration Tests Begin - Drawing the Boundary and a Practical Decision Table
A practical guide for engineers on how to split responsibilities between unit and integration tests, organized around judgment vs. connec...
Serial Communication App Pitfalls - Sort Out 1-Byte Reads, Timeouts, Flow Control, Reconnects, USB Adapters, and UI Freezes Up Front
A practitioner-oriented guide to the points serial communication apps trip on — framing, multiple kinds of timeout, RTS/CTS and DTR, reco...
Choosing Between Windows Forms, WPF, and WinUI - A Decision Table for New Builds, Existing Assets, Deployment, and UI Needs
A practical decision table for picking Windows Forms, WPF, or WinUI based on whether you are starting fresh or extending existing assets,...
Shared Memory Pitfalls and Best Practices - Sort Out Synchronization, Visibility, Lifetime, ABI, and Security First
A practical breakdown of the typical pitfalls of shared memory in production - synchronization, visibility, lifetime, ABI, permissions, a...
Related Topics
These topic pages place the article in a broader service and decision context.
Windows Technical Topics
Topic hub for KomuraSoft LLC's Windows development, investigation, and legacy-asset articles.
32-bit / 64-bit Interoperability
Topic page for 32-bit / 64-bit interoperability, native boundaries, and related Windows design decisions.
Where This Topic Connects
This article connects naturally to the following service pages.
Windows App Development
We support Windows desktop applications that involve resident processing, device integration, operational logging, and maintainable structure.