← Back to portfolio RU

Native C++ / .NET 8 Interop Bridge

RiverLogic Inc. — deployment & provisioning tooling

Part of RiverLogic's on-premise deployment and provisioning tooling: some native, legacy components needed to run logic — SQL Server prerequisite checks, connection-string and config-file patching, Windows service management, credential validation — that would have been far more tedious and error-prone to reimplement in C++ than to keep in an existing, well-tested C# codebase. Rather than porting that logic natively, I built a thin native bridge that hosts the CoreCLR runtime in-process and calls straight into the C# implementation.

How It Works

The native component links against nethost and uses the official .NET hosting API (hostfxr.h, coreclr_delegates.h) — the same low-level mechanism .NET itself uses to bootstrap — to locate and initialize the CoreCLR runtime for a given .runtimeconfig.json. It then resolves a raw function pointer to a static C# method matching a custom unmanaged-callable delegate signature, and calls straight through it. No COM, no out-of-process RPC, no C++/CLI mixed-mode compilation — just the runtime hosting APIs Microsoft documents and ships, applied to bridge into an existing managed codebase from a native component that can't easily become managed itself.

Bootstrapping the Runtime

Two steps happen before any managed code can run. First, nethost locates hostfxr — the component that knows how to pick the right installed .NET runtime — and its three exports are loaded by hand via GetProcAddress. Second, that hostfxr handle is used to initialize the runtime for a specific .runtimeconfig.json and hand back the one delegate type that matters: load_assembly_and_get_function_pointer, which is what everything else in the bridge is built on.

nativehost.cpp — runtime bootstrap

bool load_hostfxr(wchar_t const* root_path)
{
    char_t buffer[MAX_PATH];
    size_t buffer_size = sizeof(buffer) / sizeof(char_t);
    if (get_hostfxr_path_wrapper(root_path, buffer, &buffer_size) != 0)
        return false;

    void* lib = load_library(root_path, buffer);
    init_fptr        = (hostfxr_initialize_for_runtime_config_fn)
                            get_export(root_path, lib, "hostfxr_initialize_for_runtime_config");
    get_delegate_fptr = (hostfxr_get_runtime_delegate_fn)
                            get_export(root_path, lib, "hostfxr_get_runtime_delegate");
    close_fptr        = (hostfxr_close_fn)
                            get_export(root_path, lib, "hostfxr_close");

    return (init_fptr && get_delegate_fptr && close_fptr);
}

load_assembly_and_get_function_pointer_fn get_dotnet_load_assembly(const char_t* config_path)
{
    void* load_assembly_and_get_function_pointer = nullptr;
    hostfxr_handle cxt = nullptr;

    int rc = init_fptr(config_path, nullptr, &cxt);
    if (cxt == nullptr)
    {
        std::cerr << "Init failed: " << std::hex << std::showbase << rc << std::endl;
        close_fptr(cxt);
        return nullptr;
    }

    rc = get_delegate_fptr(cxt, hdt_load_assembly_and_get_function_pointer,
                            &load_assembly_and_get_function_pointer);
    if (rc != 0 || load_assembly_and_get_function_pointer == nullptr)
        std::cerr << "Get delegate failed: " << std::hex << std::showbase << rc << std::endl;

    close_fptr(cxt);
    return (load_assembly_and_get_function_pointer_fn)load_assembly_and_get_function_pointer;
}

Native Side: A Generic Function Loader

The .NET hosting sample this is built on demonstrates loading one hardcoded function. I generalized it into a reusable loader: given a runtime root, assembly name, namespace, function name, and delegate type name, it returns a ready-to-call function pointer for any exported C# method — so adding a new native-callable function is a one-line call, not a copy-pasted hosting routine.

nativehost.cpp (generalized)

void* dotnet_clr_init(string_t root_path, string_t assembly_name,
                       string_t func_namespace, string_t func_name,
                       string_t delegate_name)
{
    bool ok = load_hostfxr(root_path.c_str());
    assert(ok && "Failure: load_hostfxr()");

    const string_t config_path = root_path + assembly_name + STR(".runtimeconfig.json");
    auto load_assembly_and_get_function_pointer = get_dotnet_load_assembly(config_path.c_str());
    assert(load_assembly_and_get_function_pointer != nullptr);

    const string_t dotnetlib_path = root_path + assembly_name + STR(".dll");
    const string_t dotnet_type = assembly_name + STR(".") + func_namespace + STR(", ") + assembly_name;

    void* result = nullptr;
    int rc = load_assembly_and_get_function_pointer(
        dotnetlib_path.c_str(), dotnet_type.c_str(),
        func_name.c_str(), delegate_name.c_str(), nullptr, (void**)&result);
    assert(rc == 0 && result != nullptr);

    return result;
}

// One native export using the loader above
long WINAPI GetSqlServerVersion(HWND owner, wchar_t const* root_path,
                                 wchar_t const* connection_string, char* out_result)
{
    typedef long (CORECLR_DELEGATE_CALLTYPE* get_version_fn)(HWND, wchar_t const*, char*);

    auto get_version = (get_version_fn)dotnet_clr_init(
        root_path, STR("DeploymentSupportNET"), STR("FuncFacade"),
        STR("GetSqlServerVersion"),
        STR("DeploymentSupportNET.FuncFacade+GetSqlServerVersionDelegate, DeploymentSupportNET"));

    return get_version(owner, connection_string, out_result);
}

The native caller doesn't resolve this DLL's exports by name at link time — it calls a specific __stdcall-decorated symbol. Each export gets its calling-convention-correct linker alias declared explicitly, and the header exposes both the extern "C" declaration and a matching function-pointer typedef for callers that load the DLL dynamically.

FuncFacadeNative.h — export declarations

#ifdef DEPLOYSUPPORT_EXPORTS
#define DEPLOYSUPPORT_API __declspec(dllexport)
#pragma comment(linker, "/export:GetSqlServerVersion=_GetSqlServerVersion@16")
#else
#define DEPLOYSUPPORT_API __declspec(dllimport)
#endif

extern "C" long DEPLOYSUPPORT_API WINAPI GetSqlServerVersion(
    HWND owner, wchar_t const* root_path,
    wchar_t const* connection_string, char* out_result);

typedef long (WINAPI *GetSqlServerVersionFunc)(
    HWND owner, wchar_t const* root_path,
    wchar_t const* connection_string, char* out_result);

Managed Side: Consistent Delegate + Marshalling Pattern

On the C# side, every native-callable function follows the same shape: a delegate the native loader can bind to, platform-aware marshalling of the raw string pointers the native caller passes in, structured logging on entry, and centralized error handling that surfaces failures back to the user rather than crashing the native host silently.

FuncFacade.cs (generalized)

public static class FuncFacade
{
    public delegate int GetSqlServerVersionDelegate(
        IntPtr owner, IntPtr connectionStringPtr, StringBuilder result);

    public static int GetSqlServerVersion(
        IntPtr owner, IntPtr connectionStringPtr, StringBuilder result)
    {
        string connectionString = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
            ? Marshal.PtrToStringUni(connectionStringPtr)
            : Marshal.PtrToStringUTF8(connectionStringPtr);

        try
        {
            Logging.WriteLogEntry(nameof(GetSqlServerVersion),
                $"invoked with connectionString={connectionString}");

            string value = SqlSupport.ExecuteSqlScalar(
                connectionString, "SELECT SERVERPROPERTY('ProductVersion')");
            result.Clear().Append(value);
            return true.GetHashCode();
        }
        catch (Exception ex)
        {
            Logging.WriteErrorLogEntry(nameof(GetSqlServerVersion), ex.ToString());
            ShowMessageBox(owner, nameof(GetSqlServerVersion), ex.Message);
            return false.GetHashCode();
        }
    }

    public delegate int IsValidIdentifierDelegate(IntPtr owner, IntPtr inputPtr);

    public static int IsValidIdentifier(IntPtr owner, IntPtr inputPtr)
    {
        string input = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
            ? Marshal.PtrToStringUni(inputPtr)
            : Marshal.PtrToStringUTF8(inputPtr);

        try
        {
            Logging.WriteLogEntry(nameof(IsValidIdentifier), $"invoked with input={input}");
            return InputValidation.IsAlphaNumeric(input) ? true.GetHashCode() : false.GetHashCode();
        }
        catch (Exception ex)
        {
            Logging.WriteErrorLogEntry(nameof(IsValidIdentifier), ex.ToString());
            ShowMessageBox(owner, nameof(IsValidIdentifier), ex.Message);
            return false.GetHashCode();
        }
    }
}

Same shape for a one-line validation check as for a SQL round-trip — the boilerplate (marshalling, logging, error surfacing) is identical either way, so adding a new bridged function is almost entirely about the one line of actual logic.

Hands-On Result

This is a generalized, sanitized write-up of a real production technique used in RiverLogic's deployment tooling — function names, product-specific logic, and business details have been abstracted out. Happy to walk through the actual implementation in more depth on request.