Calling Native DLLs from C#: C++/CLI Wrapper vs P/Invoke
· Go Komura · 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
- The Conclusion First (In One Line)
- Cases Where P/Invoke Is Sufficient
- The Boundary Where P/Invoke Suddenly Gets Painful
- The Architecture With a C++/CLI Wrapper
- What C++/CLI Makes Easier
- Code Excerpts
- Cases Where You Still Should Not Choose C++/CLI
- Conclusion
- 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
SafeHandleandStructLayoutnaturally 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::wstringdirectly 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.
flowchart LR
Cs[C# app] -->|API designed for .NET| Wrapper[C++/CLI wrapper DLL]
Wrapper -->|Works directly with native headers and types| Native[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 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:
stringbyte[]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::wstringandstd::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
- Complete sample code for this article (native C++ library, C++/CLI wrapper, C# consumers) - komurasoft-blog-samples (GitHub)
- 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.
Calling a C# Native AOT DLL from C/C++
How to publish a C# class library as a native DLL with Native AOT and call UnmanagedCallersOnly entry points from C/C++ — when this setup...
Windows App Outsourcing and Contract Development: What to Sort Out Before You Ask
Before commissioning Windows app outsourcing or contract development, here is how to sort out existing software modification, device inte...
Serial Communication App Pitfalls - Through Reconnection and Log Design
The serial communication app pitfalls you want to avoid in device integration and instrument control, organized from a practical perspect...
Choosing Between WinForms, WPF, and WinUI - A Practical Decision Table
How to decide between WinForms, WPF, and WinUI, organized from the perspectives of new development, existing assets, deployment, UI expre...
Shared Memory Pitfalls and Practical Best Practices
The pitfalls of using shared memory in production, and a design approach that lowers the accident rate by covering synchronization, visib...
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.
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.
Public links