Building a Modern Cross-Platform C++ Plugin System with Runtime Loading and dlfcn-win32

Sun 05 July 2026
written by Xavier Figuera Sun 05 July 2026

Building a Modern Cross-Platform C++ Plugin System with Runtime Loading and dlfcn-win32

Modern software is expected to be modular, maintainable and easy to extend. Whether you're developing a game engine, a rendering engine, a CAD application, an editor, or any other extensible C++ application, there is one question that almost every large project eventually needs to answer:

How can new functionality be added without recompiling the entire application?

One of the most elegant solutions is to adopt a plugin architecture.

Instead of linking every module directly into the executable, optional functionality is distributed as independent shared libraries that are loaded dynamically at runtime.

This approach has become a standard design pattern across the software industry because it provides excellent scalability, clean separation of responsibilities and a high degree of flexibility.

In this article we'll explore how to build a modern cross-platform C++ plugin system using:

  • Modern C++
  • Runtime loading
  • Shared libraries (DLL / SO)
  • Abstract interfaces
  • Factory functions
  • CMake
  • dlfcn-win32

Although the example presented here is intentionally simple, the architecture closely resembles those used by professional software such as game engines, rendering engines and large desktop applications.


New to C++ plugin architectures?

This article is a comprehensive reference guide intended for readers who want to explore every aspect of the implementation.

If you're looking for a quicker introduction, you may prefer the companion tutorial:

How to Build a Cross-Platform C++ Plugin System


Table of Contents


Source code repository link

Flow - C++ Plugin System Example - Flow Labs Research


Static Linking vs Plugin Architecture

Before implementing a plugin system, it's worth understanding how it differs from a traditional statically linked application.

Although both approaches are perfectly valid, they solve different problems.

A statically linked application is often simpler to build and deploy, making it an excellent choice for smaller projects.

As applications grow, however, adding new functionality frequently requires rebuilding and redistributing the entire application.

A plugin architecture takes a different approach.

Instead of embedding every feature directly into the executable, optional functionality is moved into independently compiled shared libraries that are loaded only when required.

The following table summarizes the main differences.

Static Linking Plugin Architecture
Functionality is linked at compile time Functionality is loaded at runtime
Rebuilding is required after adding new features New plugins can often be added without recompiling
Strong compile-time dependencies Loose coupling through interfaces
Larger executable Smaller core application
Difficult to distribute optional features Features can be distributed independently
Tightly integrated modules Modular and extensible design
Simple deployment Flexible deployment

Neither approach is universally better.

For small utilities, static linking is often the simplest solution.

For large extensible applications—such as game engines, rendering engines, IDEs or CAD software—a plugin architecture usually provides greater flexibility and long-term maintainability.

Why Use a Plugin Architecture?

Imagine you're developing a modern rendering engine.

The engine has one primary responsibility:

Render frames efficiently.

Everything else should remain as modular as possible.

Although a rendering engine depends on many subsystems, not every one of them belongs inside the core engine.

For example, the engine may require services responsible for:

  • Loading the engine's native mesh format
  • Loading the engine's native material format
  • Resource serialization
  • Shader compilation
  • Material compilation
  • Texture processing
  • Scene loading
  • Editor integration

A common mistake is to compile every one of these systems directly into the rendering engine.

At first this approach appears simple.

As the project grows, however, several problems begin to emerge.

  • Every new subsystem requires rebuilding the rendering engine.
  • Compile times continue to increase.
  • The engine becomes increasingly difficult to maintain.
  • Internal dependencies multiply over time.
  • Individual systems cannot evolve independently.
  • Replacing or upgrading a subsystem becomes progressively harder.

Eventually, the rendering engine becomes responsible for much more than rendering.

A better approach is to separate the engine from the services it consumes.

Instead of embedding every subsystem into the engine, the core renderer defines a set of abstract interfaces while independent plugins provide the concrete implementations.

For example, the rendering engine may simply request a mesh loader through an interface such as IFlowMeshLoader.

The engine does not know—or need to know—whether that loader reads a binary file, a memory stream, a network resource or any other data source.

Its only concern is that the requested service fulfills the contract defined by the SDK.

flowchart LR

Engine["Rendering Engine"]

SDK["FlowRenderEngineSDK"]

Plugin["FlowToolsFormats"]

Mesh["IFlowMeshLoader"]

Material["IFlowMaterialLoader"]

Engine --> SDK

Plugin --> SDK

Plugin --> Mesh

Plugin --> Material

Engine -. Uses Interfaces .-> Mesh

Engine -. Uses Interfaces .-> Material

This architecture cleanly separates responsibilities.

The rendering engine focuses exclusively on rendering.

Plugins implement specialized services.

The SDK defines the communication contract between both sides.

As a result, new functionality can be introduced without modifying or recompiling the core engine.

This separation not only reduces coupling but also makes the entire architecture easier to maintain, extend and test over time.


Typical Applications That Use Plugins

Plugin systems are everywhere.

Many of the applications developers use every day rely heavily on dynamically loaded modules.

Some examples include:

  • Game engines
  • Rendering engines
  • CAD software
  • IDEs
  • Image editors
  • Video editors
  • Audio workstations
  • Scientific visualization software
  • Simulation software
  • Database tools

In all these cases, the core application provides the framework while plugins provide specialized functionality.

This separation allows independent development teams to work on different components without modifying the core application.


Project Goals

This project is intentionally designed to demonstrate the architectural concepts behind a modern plugin system rather than implementing a large feature set.

The main goals are:

  • Build a reusable plugin architecture.
  • Separate interfaces from implementations.
  • Load plugins dynamically at runtime.
  • Keep compile-time dependencies minimal.
  • Support both Windows and Linux.
  • Organize projects using modern CMake.
  • Demonstrate clean software engineering practices.

Although the project is relatively small, the same architecture scales naturally to considerably larger applications.


High-Level Architecture

The entire project is divided into four independent modules.

flowchart TD

    App["SomeGraphicApp"]

    Graphics["FlowRenderEngineGraphics"]

    Plugin["FlowToolsFormats"]

    SDK["FlowRenderEngineSDK"]

    App --> Graphics

    Graphics -->|"Runtime Loading"| Plugin

    SDK -. Shared Interfaces .-> Graphics
    SDK -. Shared Interfaces .-> Plugin

Each module has a very specific responsibility.

Component Responsibility
FlowRenderEngineSDK Defines the public interfaces shared by every project
FlowRenderEngineGraphics Loads plugins and manages their lifetime
FlowToolsFormats Implements the SDK interfaces
SomeGraphicApp Uses the public interfaces exposed by the graphics library

Notice something important.

The application never communicates directly with the plugin.

Instead, every interaction passes through abstract interfaces defined inside the SDK.

This keeps both sides completely independent.


Layered Architecture

Another way to visualize the system is by looking at it as a layered architecture.

flowchart TB

Application["Application"]

Graphics["Graphics Library"]

SDK["Shared SDK"]

Plugin["Plugin"]

Implementation["Concrete Loaders"]

Application --> Graphics

Graphics --> SDK

Plugin --> SDK

Plugin --> Implementation

Each layer has a single responsibility.

This is one of the fundamental principles behind maintainable software architecture.


Compile-Time Dependencies

One of the primary objectives of this architecture is minimizing compile-time dependencies.

Only the SDK is shared between projects.

graph LR

SDK["FlowRenderEngineSDK"]

Graphics["FlowRenderEngineGraphics"]

Plugin["FlowToolsFormats"]

Application["SomeGraphicApp"]

SDK --> Graphics

SDK --> Plugin

SDK --> Application

Graphics --> Application

Notice what is missing.

There is no compile-time dependency between the application and the plugin.

The application does not even know that the plugin exists.

This dramatically reduces coupling and allows plugins to evolve independently.


Runtime Dependencies

Once the application starts, the relationships between components change completely.

Instead of relying on compile-time linking, the graphics library loads plugins dynamically.

graph LR

App["SomeGraphicApp"]

Manager["Plugin Manager"]

DLL["FlowToolsFormats.dll"]

Loader["FlowMeshLoaderPlugin"]

App --> Manager

Manager -. dlopen() .-> DLL

DLL --> Loader

This is one of the key differences between traditional software architectures and plugin-based systems.

Compile-time dependencies disappear, replaced by runtime discovery.

The application becomes significantly more flexible because plugins can be replaced, updated or extended without rebuilding the executable.


Runtime Loading Overview

From the application's point of view, requesting a mesh loader is surprisingly simple.

sequenceDiagram

participant App
participant Graphics
participant Plugin

App->>Graphics: Request Mesh Loader

Graphics->>Plugin: dlopen()

Graphics->>Plugin: dlsym(CreateMeshLoader)

Plugin-->>Graphics: IFlowMeshLoader*

Graphics-->>App: IFlowMeshLoader*

The application never instantiates any concrete implementation.

Instead, it receives a pointer to an abstract interface.

This is a classic application of dependency inversion, one of the SOLID principles.

Because the application only knows about interfaces, the implementation can change at any time without affecting the application.


Why This Design Works

Although the example is intentionally compact, it incorporates several software engineering principles that are commonly found in production codebases.

Some of its main advantages include:

  • Low coupling
  • High cohesion
  • Runtime extensibility
  • Independent module development
  • Faster incremental builds
  • Easier maintenance
  • Cleaner project organization
  • Better long-term scalability

Perhaps the greatest advantage is that new plugins can be developed and distributed independently of the host application.

As long as a plugin implements the interfaces defined by the SDK, the application can discover and load it dynamically at runtime.

This level of flexibility is one of the reasons plugin architectures remain a popular design pattern across modern C++ applications.


What's Next?

Now that we've explored the overall architecture, the next step is understanding the most important component of the entire system:

The SDK.

The SDK defines the contract that both the application and every plugin must follow.

Without it, independently developed plugins would have no reliable way to communicate with the host application.

In the next section we'll design a header-only SDK that provides a stable and reusable API shared by every project in the solution.


Designing the SDK

Before writing a single line of plugin code, we first need to define how plugins will communicate with the host application.

This is arguably the most important design decision in the entire architecture.

A common mistake is allowing the application to depend directly on classes implemented by the plugin.

Although this may work for small projects, it quickly becomes difficult to maintain as the application grows.

Instead, both sides should depend only on a shared contract.

This contract is provided by the SDK.

The SDK contains no application logic.

It contains no rendering code.

It contains no file format implementation.

Its only purpose is to define the API that every plugin must implement.

The SDK therefore becomes the common language spoken by both the application and every plugin.


The Role of the SDK

The SDK acts as a contract between completely independent projects.

It defines:

  • Abstract interfaces
  • Shared data structures
  • Enumerations
  • Common type definitions
  • Export macros
  • Version information

Nothing else.

Because the SDK only exposes declarations, both the host application and every plugin can evolve independently while remaining binary compatible.


The SDK Architecture

The SDK sits at the center of the entire project.

Every component depends on it.

Nothing depends on the implementation.

flowchart LR

SDK["FlowRenderEngineSDK"]

Host["FlowRenderEngineGraphics"]

Plugin["FlowToolsFormats"]

Application["SomeGraphicApp"]

SDK --> Host
SDK --> Plugin
SDK --> Application

Notice that there are no direct dependencies between the application and the plugin.

Everything passes through the SDK.

This dramatically reduces coupling throughout the project.


Why Is the SDK Header-Only?

One question often arises:

Why not compile the SDK as a shared library?

The answer is surprisingly simple.

The SDK contains only declarations.

There is no implementation to compile.

It consists entirely of:

  • Interface classes
  • Plain data structures
  • Enumerations
  • Constants
  • Templates
  • Utility headers

Since there is no executable code, building a library would provide little benefit while introducing an unnecessary binary dependency.

Making the SDK header-only keeps the architecture lightweight and simplifies integration.


The Dependency Graph

The dependency graph is intentionally designed to point in a single direction.

graph TD

SDK["FlowRenderEngineSDK"]

Host["FlowRenderEngineGraphics"]

Plugin["FlowToolsFormats"]

Application["SomeGraphicApp"]

SDK --> Host

SDK --> Plugin

SDK --> Application

Host --> Application

This ensures that every project can be built independently.

The plugin never includes application headers.

The application never includes plugin headers.

Only the SDK is shared.


Interface-Based Design

The SDK exposes only abstract interfaces.

For example, a mesh loader may look conceptually like this.

class IFlowMeshLoader
{
public:

    virtual ~IFlowMeshLoader() = default;

    virtual bool LoadMesh(
        const std::string& filename,
        MeshAsset& mesh) = 0;
};

Notice that there is no implementation.

The SDK simply defines what a mesh loader must be capable of.

Every plugin is free to implement this interface however it chooses.

This is one of the key principles behind interface-based architectures.


Why Use Abstract Interfaces?

Abstract interfaces provide several important advantages.

They completely separate the public API from the implementation.

flowchart LR

Application

Interface["IFlowMeshLoader"]

Plugin["FlowMeshLoaderPlugin"]

Binary["FlowMeshBinaryLoader"]

Application --> Interface

Plugin --> Interface

Plugin --> Binary

The application knows only about IFlowMeshLoader.

The plugin internally decides which implementation to instantiate.

This allows implementations to evolve without affecting client code.


The Dependency Inversion Principle

This architecture is also an excellent example of the Dependency Inversion Principle (DIP).

Instead of high-level modules depending on low-level modules, both depend on abstractions.

flowchart TB

Application

SDK["Interfaces"]

Plugin

Application --> SDK

Plugin --> SDK

Because both projects depend on abstractions rather than implementations, they remain completely independent.

This is one of the reasons plugin architectures scale so well.


Shared Data Structures

Interfaces alone are not enough.

The application and the plugin also need to exchange data.

The SDK therefore contains shared data structures such as:

  • MeshAsset
  • MaterialLibrary
  • Vertex
  • IndexBuffer
  • TextureInfo

These structures define the format of the information exchanged between both sides.

Since both projects include the same headers, they always agree on the memory layout of the exchanged data.


Versioning the SDK

One challenge with plugin systems is maintaining compatibility over time.

As new functionality is added, interfaces inevitably evolve.

A common solution is to associate a version number with the SDK.

SDK Version

Major : 1
Minor : 0
Patch : 0

Plugins can expose the SDK version they were compiled against.

When the host application loads a plugin, it can verify that both versions are compatible before creating any objects.

This simple validation prevents many difficult-to-debug runtime errors.


What Should Never Be Inside the SDK?

The SDK should remain as small and stable as possible.

Avoid placing implementation details inside the shared contract.

For example, the SDK should never contain:

  • Rendering code
  • OpenGL code
  • Vulkan code
  • File parsers
  • Internal helper classes
  • Application logic
  • Resource managers

Doing so would unnecessarily increase coupling between projects.

A good SDK is intentionally boring.

Its only purpose is to define a stable communication contract.


SDK Summary

The SDK is the foundation of the entire plugin architecture.

Rather than sharing implementations, every project shares only a common contract composed of interfaces and data structures.

This simple design has several important consequences.

  • The application never depends on plugin implementations.
  • Plugins can be developed independently.
  • Compile-time dependencies remain minimal.
  • Binary compatibility becomes easier to maintain.
  • The architecture scales naturally as new plugins are added.

With the SDK in place, we are finally ready to implement our first plugin.

In the next section we'll build the plugin itself, export factory functions and see how objects are created dynamically at runtime.

Implementing the Plugin

With the SDK in place, we can finally implement our first plugin.

The plugin is responsible for providing the concrete implementation of the interfaces defined by the SDK.

Unlike the SDK, this project contains real application code.

For example, it may include:

  • Mesh loaders
  • Material loaders
  • Texture decoders
  • Scene importers
  • Binary parsers
  • Internal helper classes

These implementation details remain completely hidden from the application.

The only visible part of the plugin is the public interface exposed through the SDK.


The Plugin Architecture

Internally, the plugin is free to organize its code however it wishes.

One possible architecture is shown below.

flowchart TD

Plugin["FlowToolsFormats"]

MeshPlugin["FlowMeshLoaderPlugin"]

MaterialPlugin["FlowMaterialLoaderPlugin"]

MeshBinary["FlowMeshBinaryLoader"]

MaterialBinary["FlowMaterialBinaryLoader"]

Plugin --> MeshPlugin
Plugin --> MaterialPlugin

MeshPlugin --> MeshBinary
MaterialPlugin --> MaterialBinary

Notice that none of these implementation classes are visible outside the plugin.

The application never knows they exist.


Why Doesn't the Application Create Plugin Objects?

One common question is:

Why doesn't the application simply include the plugin headers and create the objects directly?

For example:

FlowMeshLoaderPlugin loader;

Although this appears convenient, it completely defeats the purpose of a plugin architecture.

Doing so would create a compile-time dependency between the application and the plugin.

Every time the plugin changes, the application would need to be rebuilt.

Instead, the application should only know about the abstract interface.

IFlowMeshLoader* loader;

This keeps both projects completely independent.


The Factory Pattern

If the application cannot instantiate plugin classes directly, who creates them?

The answer is simple.

The plugin creates its own objects.

The application merely asks the plugin to do so.

This design pattern is known as the Factory Pattern.

flowchart LR

Application

Factory["CreateMeshLoader()"]

Plugin["FlowMeshLoaderPlugin"]

Application --> Factory

Factory --> Plugin

Instead of exposing constructors, the plugin exposes factory functions.

The host application calls these functions to obtain interface pointers.


Exporting Factory Functions

A typical plugin exports functions similar to these.

extern "C"
{

FLOW_PLUGIN_EXPORT
IFlowMeshLoader* CreateMeshLoader();

FLOW_PLUGIN_EXPORT
void DestroyMeshLoader(IFlowMeshLoader* loader);

}

Only these functions are visible outside the shared library.

Everything else remains private.

This keeps the plugin implementation completely encapsulated.


Why Use extern "C"?

This is one of the most important aspects of any C++ plugin system.

Without extern "C" every C++ compiler performs name mangling.

For example, the function

CreateMeshLoader()

may become something like

?CreateMeshLoader@@YAPEAVIFlowMeshLoader@@XZ

or

_Z16CreateMeshLoaderv

depending on the compiler.

The application would have no reliable way to locate these symbols.

Using extern "C" disables name mangling and guarantees a predictable exported symbol.

CreateMeshLoader

This allows dlsym() and GetProcAddress() to locate the function reliably on every platform.


Why Export Functions Instead of Classes?

Another common mistake is attempting to export entire C++ classes from a shared library.

Although technically possible, this often creates several problems.

Different compilers may generate different ABIs.

Even different versions of the same compiler can introduce binary incompatibilities.

Exporting small C-style factory functions avoids these issues almost entirely.

Instead of exposing implementation classes, the plugin exposes a stable binary interface.

flowchart LR

Application

Factory["CreateMeshLoader()"]

Interface["IFlowMeshLoader"]

Plugin["FlowMeshLoaderPlugin"]

Application --> Factory

Factory --> Plugin

Plugin --> Interface

The application never interacts with the concrete implementation.

Everything passes through the interface.


Creating Objects Inside the Plugin

Internally, the factory function simply creates the concrete implementation.

Conceptually it behaves like this.

IFlowMeshLoader* CreateMeshLoader()
{
    return new FlowMeshLoaderPlugin();
}

The application never knows which class has actually been instantiated.

The plugin is free to change its implementation at any time.

Tomorrow the factory could return

FlowMeshLoaderPluginV2

or

CompressedMeshLoader

without requiring any modification to the application.

This flexibility is one of the greatest strengths of interface-based architectures.


Destroying Objects

If the plugin creates the object, it should also destroy it.

Instead of writing

delete loader;

the application calls another exported function.

DestroyMeshLoader(loader);

This guarantees that object creation and destruction occur inside the same module.

It also avoids problems related to different runtime libraries.


Object Lifetime

The complete lifecycle of a plugin object looks like this.

sequenceDiagram

participant App
participant Plugin

App->>Plugin: CreateMeshLoader()

Plugin-->>App: IFlowMeshLoader*

App->>App: Use interface

App->>Plugin: DestroyMeshLoader()

Plugin-->>Plugin: delete object

Ownership is very clear.

The plugin owns the implementation.

The application owns only the interface pointer.


Encapsulation

One of the major advantages of this architecture is encapsulation.

The plugin hides everything except its public API.

flowchart LR

subgraph Plugin

Factory

Interface

Implementation

Parser

Utilities

end

Application --> Factory

Factory --> Interface

Interface --> Implementation

Implementation --> Parser

Implementation --> Utilities

The application cannot accidentally depend on internal classes because they are never exposed.

This keeps the architecture clean even as the project grows.


Plugin Summary

The plugin is responsible for implementing the interfaces defined by the SDK while hiding all implementation details.

Rather than exporting C++ classes directly, it exposes a small set of factory functions that create and destroy interface objects.

This design provides several important advantages.

  • Stable binary interfaces.
  • Better encapsulation.
  • Lower compile-time coupling.
  • Cleaner project organization.
  • Greater flexibility.
  • Easier long-term maintenance.

With the plugin implemented, the only remaining challenge is loading it dynamically at runtime.

In the next section we'll build the Plugin Manager, discover exported symbols using dlopen() and dlsym(), and create plugin instances without introducing any compile-time dependency on the plugin itself.

Building a Plugin Manager

At this point we have already designed the SDK and implemented the plugin.

However, there is still one important piece missing.

The application needs a mechanism capable of discovering plugins, loading shared libraries, locating exported symbols, creating plugin instances and releasing every resource correctly.

This responsibility belongs to the Plugin Manager.

Although it is often overlooked in small examples, the Plugin Manager is one of the most important components of any production-ready plugin architecture.

Rather than exposing platform-specific APIs throughout the application, it centralizes every aspect of plugin management behind a clean and reusable interface.

This greatly simplifies the rest of the codebase.


Why Do We Need a Plugin Manager?

A common beginner implementation looks something like this.

void* plugin = dlopen("FlowToolsFormats.dll", RTLD_NOW);

auto create =
    reinterpret_cast<CreateMeshLoaderFn>(
        dlsym(plugin, "CreateMeshLoader"));

IFlowMeshLoader* loader = create();

Although this works, it creates several problems.

  • Platform-specific code appears throughout the application.
  • Error handling becomes repetitive.
  • Plugin lifetime becomes difficult to manage.
  • Resource cleanup is easy to forget.
  • Every subsystem must understand how dynamic loading works.

As applications grow, this quickly becomes difficult to maintain.

Instead, all runtime loading should be delegated to a dedicated Plugin Manager.


Responsibilities of the Plugin Manager

The Plugin Manager has a single responsibility:

Manage the complete lifecycle of every plugin loaded by the application.

This includes:

  • Discovering plugins
  • Loading shared libraries
  • Validating plugins
  • Resolving exported symbols
  • Creating plugin instances
  • Destroying plugin instances
  • Managing library handles
  • Reporting errors
  • Unloading plugins

Notice that none of these responsibilities belong to the application itself.

The application should only request services.

It should never care where those services come from.


High-Level Architecture

The Plugin Manager acts as an intermediary between the application and every plugin.

flowchart LR

Application

PluginManager

Plugin

Factory

Interface

Application --> PluginManager

PluginManager --> Plugin

Plugin --> Factory

Factory --> Interface

Interface --> Application

From the application's perspective there is only one object to interact with.

Everything else remains hidden.


Separating Responsibilities

One of the primary goals of the Plugin Manager is keeping each component focused on a single responsibility.

flowchart TD

Application["Application"]

Manager["Plugin Manager"]

Library["Shared Library"]

Plugin["Plugin"]

Loader["Mesh Loader"]

Application --> Manager

Manager --> Library

Library --> Plugin

Plugin --> Loader

Each layer performs one specific task.

This makes the architecture considerably easier to understand and maintain.


The Plugin Lifecycle

Every plugin follows the same lifecycle.

stateDiagram-v2

[*] --> Discovered

Discovered --> Loaded

Loaded --> SymbolsResolved

SymbolsResolved --> Active

Active --> Released

Released --> Unloaded

Unloaded --> [*]

Keeping these states explicit greatly simplifies debugging.

It also makes it easier to report meaningful error messages whenever a plugin fails during initialization.


Discovering Plugins

The first responsibility of the Plugin Manager is locating available plugins.

For small examples, loading a single DLL directly is perfectly acceptable.

FlowToolsFormats.dll

However, larger applications usually search one or more plugin directories.

A typical layout might look like this.

Application/

    SomeGraphicApp.exe

    plugins/

        FlowToolsFormats.dll
        AssimpPlugin.dll
        ObjImporter.dll
        GltfImporter.dll
        ExperimentalLoader.dll

This approach allows new functionality to be added simply by copying a new plugin into the plugins directory.

No recompilation is required.


Runtime Discovery

The discovery process usually follows a very simple workflow.

flowchart TD

Start

Scan["Scan plugins directory"]

Found{"Plugin found?"}

Load["Load library"]

Next["Continue scanning"]

End

Start --> Scan

Scan --> Found

Found -->|Yes| Load

Found -->|No| Next

Load --> Next

Next --> End

Each plugin is treated independently.

A failure while loading one plugin should not necessarily prevent the application from loading the remaining plugins.

This makes the system significantly more robust.


Loading the Library

Once a plugin has been discovered, the Plugin Manager attempts to load it.

Conceptually, the process is very straightforward.

sequenceDiagram

participant Manager
participant Library

Manager->>Library: dlopen()

Library-->>Manager: Library Handle

If the library loads successfully, the Plugin Manager stores the returned handle for future use.

This handle becomes the application's connection to every exported function contained inside the plugin.


Managing Library Handles

Each successfully loaded plugin produces a library handle.

The Plugin Manager is responsible for keeping these handles alive for as long as any object created by the plugin still exists.

flowchart LR

PluginManager

Handle1["Library Handle"]

Handle2["Library Handle"]

PluginA

PluginB

PluginManager --> Handle1

PluginManager --> Handle2

Handle1 --> PluginA

Handle2 --> PluginB

Destroying a library handle too early would invalidate every object created by that plugin.

For this reason, handles usually remain alive until application shutdown.


Resolving Exported Symbols

Loading the shared library is only the first step.

The Plugin Manager must also locate the exported factory functions.

flowchart LR

Library

dlsym

Create["CreateMeshLoader"]

Destroy["DestroyMeshLoader"]

Library --> dlsym

dlsym --> Create

dlsym --> Destroy

Only after every required symbol has been successfully resolved can the plugin be considered usable.

If even one required symbol is missing, the plugin should be rejected.


Validating the Plugin

A production-ready Plugin Manager typically performs several validation steps before exposing the plugin to the rest of the application.

Typical checks include:

  • Required symbols exist.
  • SDK versions are compatible.
  • Plugin version is supported.
  • Initialization succeeds.
  • Mandatory interfaces are available.

Only after every validation step succeeds should the plugin become active.


Why Hide dlopen()?

One of the main objectives of the Plugin Manager is preventing the rest of the application from depending on platform-specific APIs.

Without a Plugin Manager the application quickly becomes littered with code such as:

dlopen()

dlsym()

dlclose()

Instead, higher-level systems interact with a much cleaner interface.

auto loader = pluginManager.GetMeshLoader();

The application no longer cares whether the loader came from:

  • A DLL
  • A shared object
  • A built-in module
  • A remote plugin
  • A test implementation

This level of abstraction makes the architecture significantly easier to extend.


Error Handling

Dynamic loading can fail for many reasons.

For example:

  • Plugin not found.
  • Missing dependency.
  • Invalid SDK version.
  • Missing exported symbol.
  • Corrupted library.
  • Unsupported architecture.
  • Initialization failure.

A dedicated Plugin Manager provides a single location for handling all of these situations consistently.

This also makes logging much cleaner.


Benefits of a Plugin Manager

Introducing a dedicated Plugin Manager provides numerous advantages.

  • Centralized runtime loading.
  • Cleaner architecture.
  • Better separation of responsibilities.
  • Simplified error handling.
  • Easier debugging.
  • Better scalability.
  • Improved testability.
  • Platform independence.

Perhaps most importantly, the rest of the application never needs to know how plugins are actually loaded.

That implementation detail remains completely encapsulated.


Plugin Manager Summary

The Plugin Manager is the central coordinator of the entire plugin architecture.

Rather than exposing platform-specific loading APIs throughout the codebase, it encapsulates every aspect of plugin discovery, loading, validation and lifetime management behind a single reusable component.

With the Plugin Manager in place, the application simply requests the interfaces it needs while the manager transparently loads plugins, resolves exported symbols and creates the required objects.

Now that we understand the responsibilities of the Plugin Manager, we're finally ready to examine the low-level APIs that make runtime loading possible.

In the next section we'll explore dlopen(), dlsym() and dlclose(), understand how they work internally, and see how dlfcn-win32 provides a unified API across both Windows and Linux.

Runtime Loading with dlopen(), dlsym() and dlfcn-win32

With the architecture complete, it's finally time to look at the low-level API responsible for loading plugins at runtime.

Although the Plugin Manager hides these implementation details from the rest of the application, understanding how dynamic loading works provides valuable insight into the overall architecture.

Fortunately, the API required by the Plugin Manager is surprisingly small.

In most applications only three functions are needed.

  • dlopen()
  • dlsym()
  • dlclose()

Together they provide everything required to load shared libraries, locate exported symbols and release resources.


What Is Runtime Loading?

Traditional applications link every library before the executable starts.

When the operating system launches the program, all required modules are already known.

A plugin architecture works differently.

Instead of linking every library during compilation, libraries are loaded only when they are actually needed.

flowchart LR

Executable

PluginManager

SharedLibrary

Executable --> PluginManager

PluginManager -->|"Runtime"| SharedLibrary

This ability to discover new functionality while the application is already running is one of the defining characteristics of a plugin system.


What Happens When dlopen() Is Called?

Calling dlopen() involves much more than simply opening a file.

Internally, the operating system performs several operations.

flowchart TD

Request["dlopen()"]

Locate["Locate library"]

Load["Load into memory"]

Resolve["Resolve dependencies"]

Relocate["Perform relocations"]

Initialize["Run initialization code"]

Handle["Return library handle"]

Request --> Locate

Locate --> Load

Load --> Resolve

Resolve --> Relocate

Relocate --> Initialize

Initialize --> Handle

Only after all these steps succeed does the operating system return a valid library handle.

If any step fails, the library is not loaded.


The Library Handle

A successful call to dlopen() returns an opaque handle.

void* handle = dlopen(
    "FlowToolsFormats.dll",
    RTLD_NOW);

The application should never attempt to interpret this value.

It simply acts as a reference to the loaded shared library.

Every subsequent operation uses this handle.

For example:

  • Resolving symbols
  • Querying exported functions
  • Closing the library

The Plugin Manager is responsible for storing and managing these handles.


Why Use RTLD_NOW?

One of the most commonly used loading flags is:

RTLD_NOW

This instructs the loader to resolve every required symbol immediately.

flowchart LR

Library

Resolve["Resolve all symbols"]

Ready["Plugin ready"]

Library --> Resolve

Resolve --> Ready

The main advantage is that loading failures occur immediately.

If a dependency is missing or an exported symbol cannot be resolved, the plugin never becomes active.

This makes debugging significantly easier.


Resolving Symbols with dlsym()

Loading a library only makes its code available.

The application still needs a way to locate exported functions.

This is the role of dlsym().

auto createMeshLoader =
    reinterpret_cast<CreateMeshLoaderFn>(
        dlsym(handle, "CreateMeshLoader"));

The Plugin Manager provides the library handle together with the name of the exported symbol.

If the symbol exists, a function pointer is returned.

Otherwise the lookup fails.


Symbol Resolution

The process is conceptually simple.

flowchart LR

Library

SymbolTable["Exported Symbols"]

Factory["CreateMeshLoader()"]

Library --> SymbolTable

SymbolTable --> Factory

Every shared library exposes a symbol table.

dlsym() searches this table looking for the requested function.

If the symbol is found, the application receives a callable function pointer.


Calling the Factory Function

Once the symbol has been resolved, creating objects becomes straightforward.

sequenceDiagram

participant Manager

participant Plugin

Manager->>Plugin: dlsym("CreateMeshLoader")

Plugin-->>Manager: Function Pointer

Manager->>Plugin: CreateMeshLoader()

Plugin-->>Manager: IFlowMeshLoader*

Notice that the application still never creates the object directly.

The plugin remains responsible for constructing every implementation.


Releasing the Library

Eventually the plugin is no longer required.

At that point the Plugin Manager releases the library.

dlclose(handle);

Internally this decreases the library's reference count.

When the reference count reaches zero, the operating system unloads the shared library and releases its resources.


Object Lifetime Matters

One important rule must always be respected.

Never unload a plugin while objects created by that plugin still exist.

Doing so immediately invalidates every function and every object allocated inside the shared library.

flowchart TD

PluginLoaded

ObjectCreated

ObjectDestroyed

LibraryReleased

PluginLoaded --> ObjectCreated

ObjectCreated --> ObjectDestroyed

ObjectDestroyed --> LibraryReleased

This is why the Plugin Manager usually owns every library handle until application shutdown.


Cross-Platform Loading

Windows and Linux expose different APIs for runtime loading.

Windows Linux
LoadLibrary() dlopen()
GetProcAddress() dlsym()
FreeLibrary() dlclose()

Maintaining separate implementations quickly becomes repetitive.

Fortunately, dlfcn-win32 provides a compatibility layer that exposes the familiar POSIX API on Windows.

From the application's point of view, runtime loading becomes identical on both platforms.

void* handle = dlopen(
    "FlowToolsFormats.dll",
    RTLD_NOW);

auto create =
    reinterpret_cast<CreateMeshLoaderFn>(
        dlsym(handle, "CreateMeshLoader"));

dlclose(handle);

The Plugin Manager no longer needs to care whether it is running on Windows or Linux.


Why Use dlfcn-win32?

Using dlfcn-win32 provides several advantages.

  • One implementation for every platform.
  • Cleaner source code.
  • Easier maintenance.
  • Simpler testing.
  • Reduced use of platform-specific macros.
  • Familiar POSIX interface.

Instead of maintaining two independent implementations, the Plugin Manager works with a single API.

This greatly simplifies the codebase.


The Complete Runtime Loading Process

Putting everything together, the complete loading sequence looks like this.

sequenceDiagram

participant Application

participant PluginManager

participant Library

Application->>PluginManager: Request Mesh Loader

PluginManager->>Library: dlopen()

Library-->>PluginManager: Library Handle

PluginManager->>Library: dlsym(CreateMeshLoader)

Library-->>PluginManager: Function Pointer

PluginManager->>Library: CreateMeshLoader()

Library-->>PluginManager: IFlowMeshLoader*

PluginManager-->>Application: Interface Pointer

Although several steps occur internally, the application experiences an extremely simple API.

It simply requests a mesh loader.

Everything else remains hidden behind the Plugin Manager.


Runtime Summary

Dynamic loading is one of the key technologies that makes plugin architectures possible.

Using only three functions—dlopen(), dlsym() and dlclose()—the Plugin Manager can discover shared libraries, locate exported factory functions and create interface objects without introducing any compile-time dependency on plugin implementations.

Combined with dlfcn-win32, the same source code works on both Windows and Linux, providing a clean and maintainable foundation for cross-platform C++ applications.

Now that the runtime loading mechanism is complete, the only remaining step is to organize the entire project using CMake, build every component independently and deploy the plugin alongside the application.

Organizing the Project with CMake

One of the strengths of this example is that it is intentionally split into several independent projects.

Although it would be possible to place everything inside a single CMake project, doing so would not accurately represent how plugin-based applications are typically developed.

In a real-world project, the SDK, plugins and host application are often maintained independently.

This separation improves modularity, simplifies maintenance and allows teams to work on different components without interfering with each other.


Project Organization

The solution is divided into three independent projects.

flowchart TD

SDK["FlowRenderEngineSDK"]

Plugin["FlowToolsFormats"]

Engine["FlowRenderEngine"]

SDK --> Plugin

SDK --> Engine

Each project has a clearly defined responsibility.

Project Purpose
FlowRenderEngineSDK Public interfaces shared by every component
FlowToolsFormats Plugin implementation
FlowRenderEngine Host application and Plugin Manager

Notice that the plugin and the engine never depend directly on each other.

Both depend only on the SDK.


Why Separate the Projects?

Keeping each project independent provides several advantages.

  • Smaller repositories.
  • Faster incremental builds.
  • Better modularity.
  • Cleaner dependency graph.
  • Independent versioning.
  • Easier distribution.
  • Simpler maintenance.

This organization also reflects how many commercial SDKs are distributed.

Developers receive the SDK separately and build plugins against its public API.


Installing the SDK

Because the SDK is shared by every project, it needs to be discoverable by CMake.

Although the SDK is header-only, it is still installed as a CMake package.

This allows other projects to locate it using find_package().

flowchart LR

SDK["FlowRenderEngineSDK"]

Install["cmake --install"]

Package["CMake Package"]

Plugin

Engine

SDK --> Install

Install --> Package

Package --> Plugin

Package --> Engine

The SDK therefore becomes a reusable dependency that can be consumed by any project.


Building the SDK

Since the SDK contains only headers, there is no binary library to generate.

The build process simply creates and installs the package configuration files.

cmake -S . -B build

cmake --build build

cmake --install build --prefix build/install

After installation, the SDK behaves like any other CMake package.


Using find_package()

Both the plugin and the host application locate the SDK through CMake.

Conceptually the process is straightforward.

find_package(FlowRenderEngineSDK REQUIRED)

target_link_libraries(
    MyTarget
    PRIVATE
        FlowRenderEngineSDK::FlowRenderEngineSDK)

Although the SDK is header-only, exposing it as an imported CMake target provides a consistent developer experience.

Consumers simply link against the exported interface target.


Building the Plugin

The plugin is developed independently from the engine.

The only requirement is access to the installed SDK.

cmake -S . -B build \
    -DCMAKE_PREFIX_PATH=../FlowRenderEngineSDK/build/install

cmake --build build

The resulting shared library can then be copied into the application's plugin directory.


Building the Host Application

The host application follows exactly the same workflow.

cmake -S . -B build \
    -DCMAKE_PREFIX_PATH=../FlowRenderEngineSDK/build/install

cmake --build build

Because both projects consume the same SDK package, they always compile against the same public interfaces.


Build Order

Since the projects are independent, they should be built in the following order.

flowchart LR

SDK["1. SDK"]

Plugin["2. Plugin"]

Engine["3. Engine"]

SDK --> Plugin

SDK --> Engine

The SDK must be installed before either the plugin or the engine can be configured.


Deploying the Plugin

After building the plugin, the generated shared library must be placed somewhere the application can locate it.

For this example, the simplest approach is placing the plugin next to the executable.

build/

    bin/

        SomeGraphicApp.exe

        FlowRenderEngineGraphics.dll

        FlowToolsFormats.dll

On Linux the plugin would typically be:

libFlowToolsFormats.so

Many production applications instead use a dedicated plugins directory.

For example:

Application/

    SomeGraphicApp.exe

    plugins/

        FlowToolsFormats.dll
        AssimpPlugin.dll
        GltfPlugin.dll
        ObjImporter.dll

This layout makes it possible to add or remove functionality simply by copying or deleting plugin files.

No recompilation is required.


Running the Application

When the application starts, the Plugin Manager performs the complete loading process automatically.

Conceptually the startup sequence is:

sequenceDiagram

participant App

participant Manager

participant Plugin

App->>Manager: Initialize()

Manager->>Plugin: Load shared library

Manager->>Plugin: Resolve factories

Manager->>Plugin: Create loaders

Manager-->>App: Ready

From the application's perspective, plugin loading is completely transparent.

It simply requests the interfaces it needs.


Example Output

After successfully loading the plugin, the example application produces output similar to the following.

SomeGraphicApp.exe

Mesh loaded

Vertex count: 3

Material count: 1

Although the example is intentionally small, exactly the same startup sequence can scale to applications capable of loading dozens or even hundreds of plugins.


CMake Summary

By separating the SDK, the plugin and the host application into independent CMake projects, the architecture remains clean, modular and easy to maintain.

Installing the SDK as a CMake package allows every component to share a stable public API while remaining completely independent.

This organization closely resembles the structure used by many professional software projects and provides an excellent foundation for building large extensible applications.

With the build system complete, the final step is reviewing the overall architecture, discussing best practices, answering common questions and summarizing the key ideas presented throughout this article.

Frequently Asked Questions

Building a plugin architecture is often more about software design than about dynamic loading itself.

The following questions summarize many of the most common topics developers encounter when designing extensible C++ applications.


What is a C++ plugin?

A C++ plugin is an independently compiled shared library that extends the functionality of an application without requiring the application itself to be recompiled.

Rather than being linked directly into the executable, plugins are loaded dynamically at runtime.

This allows new features to be added simply by distributing additional shared libraries.


What is a plugin architecture?

A plugin architecture is a software design pattern where the application provides a framework while optional functionality is implemented by external modules.

The host application defines a public API.

Plugins implement that API.

This separation allows both projects to evolve independently.


Why use plugins instead of static libraries?

Static libraries are linked during compilation.

Any modification requires rebuilding the application.

Plugins are loaded dynamically.

This makes it possible to distribute updates independently, reduce compile-time dependencies and extend applications without recompilation.


Why use abstract interfaces?

Abstract interfaces separate the public API from the implementation.

The application depends only on the interface.

The plugin provides the implementation.

This dramatically reduces coupling and improves maintainability.


Why shouldn't the application instantiate plugin classes directly?

Creating plugin objects directly introduces a compile-time dependency on the plugin implementation.

Using factory functions allows the plugin to remain completely independent from the host application.


Why use factory functions?

Factory functions provide a stable binary interface between the host application and the plugin.

Instead of exposing constructors, the plugin exports small C-style functions that create and destroy interface objects.


Why use extern "C"?

Without extern "C" the compiler performs C++ name mangling.

Name mangling makes exported symbols compiler-dependent.

Using extern "C" guarantees predictable symbol names that can be located using dlsym() or GetProcAddress().


What is name mangling?

Name mangling is the process by which C++ compilers encode additional information into symbol names.

This allows features such as function overloading.

Unfortunately, mangled names are compiler-specific.

Plugin systems therefore usually export C-compatible functions instead.


Why not export entire C++ classes?

Exporting complete classes can introduce ABI compatibility issues between compilers or compiler versions.

Exporting small factory functions together with abstract interfaces provides a considerably more stable binary interface.


What is dlopen()?

dlopen() loads a shared library into the application's address space and returns a handle that can later be used to locate exported symbols.


What is dlsym()?

dlsym() searches a loaded shared library for a specific exported symbol and returns its address.

Plugin managers typically use it to locate factory functions.


What is dlclose()?

dlclose() releases a previously loaded shared library.

When no remaining references exist, the operating system unloads the library from memory.


Why use RTLD_NOW?

RTLD_NOW instructs the operating system to resolve all required symbols immediately.

This causes loading errors to appear during initialization rather than later during execution.


What is dlfcn-win32?

dlfcn-win32 is a compatibility library that implements the POSIX dynamic loading API on Windows.

It allows the same source code using dlopen(), dlsym() and dlclose() to compile on both Windows and Linux.


Why use dlfcn-win32 instead of LoadLibrary()?

Using dlfcn-win32 allows the entire Plugin Manager to be implemented using a single cross-platform API.

This significantly reduces platform-specific code.


Can multiple plugins implement the same interface?

Yes.

A plugin architecture often supports multiple implementations of the same interface.

For example, several mesh importers may all implement the same IFlowMeshLoader interface while supporting different file formats.


Can plugins depend on other plugins?

Yes.

However, dependencies between plugins should generally be minimized.

Keeping plugins independent makes deployment and maintenance considerably easier.


Can plugins be loaded after the application starts?

Yes.

One of the primary advantages of runtime loading is that plugins can be discovered and loaded while the application is already running.


Is a Plugin Manager really necessary?

For small examples it may not be strictly necessary.

However, larger applications quickly benefit from centralizing plugin discovery, loading, validation, lifetime management and error handling inside a dedicated Plugin Manager.


Can this architecture scale to a game engine?

Absolutely.

The architecture presented in this article is intentionally simple, but the underlying principles scale naturally to considerably larger projects.

Game engines frequently use plugins for:

  • Asset importers
  • Rendering backends
  • Physics engines
  • Audio systems
  • Scripting languages
  • Editor extensions

Does this architecture work on Linux?

Yes.

Because runtime loading is implemented through the POSIX API and dlfcn-win32 provides the same interface on Windows, the Plugin Manager remains almost entirely platform independent.


Can this architecture support dozens of plugins?

Yes.

The Plugin Manager can maintain multiple library handles simultaneously and dynamically discover every compatible plugin located inside the plugin directory.

Many professional applications load hundreds of plugins using this same architectural approach.


Is this architecture suitable for commercial software?

Yes.

Separating the SDK, the host application and plugins into independent projects is a common design used by many commercial applications because it provides excellent modularity, scalability and long-term maintainability.


Common Mistakes

Although building a plugin system is conceptually straightforward, several design mistakes appear repeatedly in real-world projects.

Fortunately, most of them are easy to avoid once you understand how a plugin architecture is intended to work.

Some of the most common mistakes include:

  • Exporting concrete C++ classes instead of abstract interfaces.
  • Forgetting to use extern "C" for exported factory functions.
  • Calling delete from the host application instead of the plugin.
  • Unloading a shared library while objects created by that library are still alive.
  • Ignoring SDK version compatibility.
  • Exposing internal implementation details through the public API.
  • Mixing platform-specific loading code throughout the application instead of centralizing it inside a Plugin Manager.

Avoiding these pitfalls will make your plugin architecture considerably more robust and easier to maintain.

Several of these topics deserve a much deeper discussion and will be explored in future articles.


What's Next?

The architecture presented in this article provides a solid foundation for building extensible C++ applications.

However, many advanced topics remain beyond the scope of this introduction.

Future articles in this series will explore subjects such as:

  • Designing a production-ready Plugin Manager.
  • Supporting multiple plugins implementing the same interface.
  • SDK version negotiation and binary compatibility.
  • Plugin metadata and discovery.
  • Hot reloading during development.
  • Building a plugin registry.
  • Common ABI pitfalls in C++ plugin systems.
  • Best practices for long-term maintainability.

Together, these topics will gradually evolve this simple example into a production-ready plugin architecture suitable for larger applications.

Final Thoughts

Designing a plugin architecture is about much more than simply calling dlopen().

The real challenge lies in defining stable interfaces, minimizing compile-time dependencies and creating a clean separation between the host application and plugin implementations.

By introducing a shared SDK, abstract interfaces, factory functions and a dedicated Plugin Manager, we've built an architecture that is modular, extensible and portable across multiple operating systems.

Although the example presented in this article is intentionally compact, the same principles can scale to significantly larger projects such as rendering engines, game engines, CAD software and professional desktop applications.

Hopefully this article has demonstrated that a modern C++ plugin system is not only powerful, but also remarkably elegant when built upon solid software engineering principles.

Happy coding!

Authored by: > Xavier Figuera - 2026 > LinkedIn | Website