DLL and COM Interface Backward Compatibility — A Decision Table for Which Changes Break Callers

· · COM, DLL, .NET, C#, C++, Backward Compatibility, Versioning, Legacy Technology, Legacy Asset Reuse, Decision Table

“Can we just swap in the new DLL for this fix, or do the callers need a rebuild too?” — if you maintain a shared DLL or COM component that multiple applications reference, you end up answering this question at every release. Get it wrong, and an old EXE still running at a client site can stop launching entirely, or worse, it keeps launching just fine while its calculation results quietly change underneath it.

What makes this tricky is that the call tends to get made on a vague “this feels risky” hunch. In reality, which changes break compatibility can be determined almost mechanically. Native DLLs have well-defined rules around exports and calling conventions; COM has the explicit, written-down iron rule that interfaces are immutable1; and .NET has a published list of compatibility change rules that Microsoft itself uses when developing the .NET libraries2.

On this blog, we’ve already covered the basics of COM in What Is COM / ActiveX / OCX? and its design philosophy in What Is COM — Why Windows COM’s Design Still Holds Up Today. This article organizes, as a decision table, which changes break callers for DLLs, COM, and .NET assemblies respectively, and covers the procedure to follow when breaking compatibility becomes unavoidable.

1. The Bottom Line First

  • Compatibility has three layers: binary compatibility (works without a rebuild), source compatibility (works if you rebuild), and behavioral compatibility (behavior doesn’t change). “No rebuild required” does not equal “safe” — you need to judge behavioral compatibility too.3
  • For native DLLs, the baseline rule is adding an export is safe; changing or removing an existing export is breaking. Function signatures, calling conventions, and struct layout are the binary contract itself.
  • A COM interface is immutable once published. Adding, removing, or reordering methods after publication violates the specification; changes must be added as a new interface with a new IID (IFoo → IFoo2).41
  • VB6/VBA clients use early binding, which bakes in vtable slot positions, making them the callers most likely to break from any change to an interface’s layout.
  • For .NET, what counts as a breaking change to a public API is published as Microsoft’s compatibility change rules, and the list classifies not just method removal or signature changes but also adding virtual to a member and even renaming a parameter as breaking.2
  • Semantic versioning is a convention of bumping the major version on a breaking change, but it only works once you declare a definition of what counts as breaking.5 You can use this article’s decision table as that definition.
  • When breaking compatibility is unavoidable, proceed in this order: offer old and new side by side → a deprecation period → an inventory of callers → retirement. The principle is never to swap it out all at once.

2. The Three Layers of Compatibility — Who Breaks, and When

What gets casually lumped together as “backward compatibility” actually breaks down into three layers. Even .NET’s official documentation classifies breaking changes along the lines of source, binary, and behavioral compatibility.3

Layer Meaning What happens when it breaks Who mainly suffers
Binary compatibility Callers work with the new DLL without a rebuild Entry point not found at startup, a MissingMethodException at run time, crashes Old EXEs still running at client sites, third-party apps you can’t rebuild
Source compatibility Callers work if you rebuild them Compile errors the next time they’re built Another team in-house, developers who hold the source
Behavioral compatibility The specified behavior doesn’t change Results, timing, or the kind of exception thrown change with no error at all End users (and everyone who ends up investigating the resulting incident)

The important point about these three layers is that an outer layer can break even when an inner layer stays intact. For example, a fix that changes the meaning of an existing function’s return value keeps both binary and source compatibility intact while breaking only behavioral compatibility. Because this kind of change produces neither a link error nor a compile error, it’s the row in the decision table that’s easiest to overlook.

Conversely, in an environment where every caller holds the source and can be rebuilt at the same time (a single-repo in-house system, say), you only need to preserve source and behavioral compatibility — binary compatibility can be dropped from the requirements. Whether any caller of your DLL is a binary you can’t rebuild is the first fork in the road when reading the decision table.

3. The Compatibility Decision Table for Native DLLs (C/C++)

A native DLL’s compatibility is determined by its export table, calling conventions, and memory layout. How a DLL gets found and loaded in the first place is covered in How Windows DLL Name Resolution Works; once loading succeeds, the table below is how you judge compatibility.

Change Binary compatibility Notes
Adding an exported function Doesn’t break The safest way to extend an API. However, if you rely on implicit ordinals from a .def file, adding a function can renumber existing ordinals depending on where it lands; if any client links by ordinal, pin the existing ordinals explicitly and append new ones at the end
Removing or renaming an exported function Breaks Import resolution fails, producing an error at load time or from GetProcAddress
Changing an existing function’s signature (adding/removing/retyping parameters, changing the return type) Breaks Stack and register passing conventions no longer line up. The same goes for return values — switch from an integer (RAX) to a floating-point value (XMM0) and the caller reads garbage under the old ABI. It can run without an error and simply go off the rails
Changing the calling convention (__cdecl <-> __stdcall) Breaks (32-bit) On x86, responsibility for cleaning up the stack swaps, leading to stack corruption. x64 has a single calling convention and effectively ignores these specifiers, so this row is a 32-bit-DLL concern
Changing an export ordinal Breaks, conditionally A caller linking by ordinal ends up calling a different function. No impact if every caller links by name only
Adding a member to a struct the caller allocates Breaks An old caller still allocates and passes the smaller version (this can be mitigated with the cbSize convention discussed below)
Changing a public struct’s packing/alignment (#pragma pack, /Zp, a toolchain change) Breaks The offsets of existing members and the overall size change even if you don’t touch a single member. cbSize can’t rescue you from a shifted layout, so pin packing explicitly in the public header
Internal changes to a struct that only the DLL allocates and frees Doesn’t break If the design only exposes a pointer (a handle) externally, the internals are free to change
Changing the meaning of a return value or error code Doesn’t break (but behavioral compatibility does) Linking still succeeds while behavior changes — the pattern that’s slowest to be discovered
Adding a data member or virtual function to a C++ class exported directly Breaks Object size or vtable layout changes. Adding only a non-virtual member function leaves the layout unchanged and doesn’t directly break existing clients, but directly exporting a C++ class has no cross-compiler compatibility to begin with, and having to make this judgment call every single time is itself a sign of a fragile ABI

The design guidance this table leads to hasn’t changed in decades: keep the boundary to a C ABI (extern "C" functions and plain structs), and extend by adding functions. The same applies even when you’re building a native DLL from C#: the export surface covered in How to Call a C# Native AOT DLL From C/C++ is managed exactly according to this table.

3.1 The cbSize Convention — Win32’s Trick for Making Structs Extensible

The classic countermeasure to “adding a struct member breaks callers” is the Win32 convention of putting a size field at the head of the struct. The caller fills in cbSize with the struct size it knew about at compile time and passes that in; the DLL looks at that size to determine which generation of the struct this particular caller understands.

typedef struct KS_CONFIG {
    DWORD cbSize;      // The caller sets sizeof(KS_CONFIG)
    DWORD dwMode;
    DWORD dwTimeout;
    // Always add future members at the end
} KS_CONFIG;

// DLL side: use cbSize to tell generations apart, and fall back to a default for old callers
if (pConfig->cbSize >= FIELD_OFFSET(KS_CONFIG, dwTimeout) + sizeof(DWORD)) {
    timeout = pConfig->dwTimeout;   // New caller
} else {
    timeout = DEFAULT_TIMEOUT;      // Old caller
}

In fact, the Windows API’s NOTIFYICONDATA struct is versioned by generation using exactly this scheme, and it’s officially documented that setting the right value in cbSize lets you stay compatible with older versions of Shell32.dll.6 If you put cbSize into your own DLL’s public structs starting from the very first version, later extensions move from “breaking change” to the safe side of the decision table. That said, new members must always go at the end, and changing the type or order of an existing member is still forbidden. There’s one more thing: for a struct used for output, the DLL side takes on extra responsibility — writes and initialization must always stay within the cbSize the caller actually passed in. Unconditionally writing the full new sizeof overruns the smaller buffer an old caller allocated, and the DLL ends up causing exactly the kind of breakage this convention was supposed to prevent.

4. The Iron Rule of COM Interfaces — No Changes Once Published

COM is the technology that gave the clearest answer to this problem. Under the COM specification, an interface follows these rules:

  • An interface has a unique IID (interface ID).1
  • An interface is immutable. Once created and published, no part of its definition may ever be changed.1
  • Adding or removing a method, or changing its semantics, doesn’t mean creating “a new version of the old interface” — it means creating a new interface with a different IID.4

The reason for such strictness is that a COM interface’s real substance is a binary layout — the vtable, a table of function pointers. A C++ or VB6 client bakes in, at compile time, the fact that “slot 3 is GetName” — a position. Insert a method after publication, and the old client will, with no error at all, call a different method. That’s exactly why COM erased the very operation of “changing” an interface from the spec, and provided the following extension procedure instead.

// v1: already published. Never change this again
[object, uuid(1111....)]
interface ICalc : IUnknown {
    HRESULT Add([in] long a, [in] long b, [out, retval] long* result);
};

// v2: a new interface with a new IID. Extends by inheriting from ICalc
[object, uuid(2222....)]
interface ICalc2 : ICalc {
    HRESULT AddChecked([in] long a, [in] long b, [out, retval] long* result);
};

The implementing class (the coclass) implements both ICalc and ICalc2; old clients keep using ICalc exactly as before, and new clients request ICalc2 via QueryInterface. The official versioning theory for RPC/COM lays it out the same way: a new interface that inherits from the old one is the equivalent of a minor version bump, while changing an existing method or type requires an entirely new, non-inheriting interface — the equivalent of a major version bump.7 What makes this scheme work is that QueryInterface lets a caller safely check, at run time, which interfaces are supported. The design elegance behind this mechanism is something we dug into in What Is COM.

4.1 Dividing Responsibility Among CLSID, ProgID, and IID

When thinking about COM versioning, it helps to separate the roles of three kinds of identifiers.8

  • IID identifies an interface (a contract). Any change to the contract always means a new IID.
  • CLSID identifies the implementing class. You’re free to swap the implementation while keeping the same CLSID, as long as you honor the contract of every published interface.
  • ProgID is a human-readable alias (KomuraSoft.Calc.1) used to look up the corresponding CLSID in the registry. It’s conventional to keep both a version-numbered ProgID and a version-independent ProgID (KomuraSoft.Calc) that always points at the latest version, with the latter mapped to the current version via CurVer.8

In other words, “upgrading the implementation” belongs to the world of CLSID and ProgID, while “changing the contract” belongs to the world of IID — don’t conflate the two. If you’d rather avoid registry registration altogether, that option is covered in What Is Reg-Free COM?

4.2 Why VB6/VBA Clients Are Especially Fragile

When VB6 or VBA uses a COM component through a project reference (early binding), it reads the type library at compile time to resolve calls. Early binding is the recommended form — you get IntelliSense, type checking, and faster execution9 — but the price is that it binds tightly to the type library’s layout. Not only does an interface’s vtable changing cause trouble; even a change purely to the type library’s definitions can surface as “the reference was broken when I opened the project” or as runtime error 430/438.

That means components with VB6, VBA, or Excel macros as callers need to enforce the interface-immutability rule most strictly of all. Type libraries also carry a version (major.minor), and you bump it whenever you extend the contract. Generating a type library when you’re publishing typed .NET code to VBA is covered in Calling a Typed .NET 8 DLL From VBA — dscom and TLB. Late-bound clients that only use CreateObject, on the other hand, resolve by name and so are resilient to layout changes, but they’re just as exposed to a change in a method’s meaning (behavioral compatibility).

5. .NET Assembly Compatibility — Judging It Mechanically With the Official Rules

.NET has a published set of “change rules for compatibility” that Microsoft itself uses when developing the .NET libraries, classifying each kind of change as allowed (✔️), forbidden (❌), or needs-judgment (❓).2 The documentation explicitly states you can adopt it as-is as the criteria for your own libraries, so here are the main rows.

Change to a public API Verdict Notes
Adding a method, type, or member ✔️ Safe in principle Watch out, though, for an addition that changes existing overload resolution. Adding an instance field to a public struct is an exception: it changes size and layout, breaking interop and unsafe consumers
Removing or renaming a public type or member ❌ Breaking Breaks at run time with a MissingMethodException or similar
Changing a signature (adding/removing/reordering/retyping parameters, or the return type) ❌ Breaking Breaks both binary and source compatibility
Renaming a parameter ❌ Breaking Breaks C# named arguments and VB late binding. Easy to overlook
Adding virtual to a member ❌ Breaking A classic trap that looks safe because it’s “just an addition.” A mismatch between call/callvirt IL instructions can result
Removing virtual, or making a virtual member abstract ❌ Breaking Breaks overrides in derived classes
Adding an abstract member to a non-sealed public type ❌ Breaking Existing derived classes don’t have an implementation for it
Sealing a type ❌ Breaking Existing derived classes stop compiling
Adding a member to an interface ❓ Needs judgment Can be mitigated with a default interface implementation (DIM), but that comes with language/runtime conditions
A constant, or changing an enum value, or renaming/removing an enum member ❌ Breaking The value gets baked into the caller at compile time
Changing code to throw a more derived exception ✔️ Allowed Existing catch blocks keep working
Throwing a new kind of exception on an existing code path ❌ Breaking Throwing it only for a new parameter value is fine

It isn’t as simple as COM’s “interfaces are immutable,” but the underlying philosophy is the same: a public API is a contract; you can add to the contract, but you cannot change the existing contract. And the fact that changes like adding a virtual method or renaming a parameter — the kind of thing that looks safe at a glance — are classified as breaking is exactly why you should judge by the table rather than by feel.

5.1 Strong Names and Three Version Numbers

.NET assemblies carry several version numbers, each with a different role.10

  • AssemblyVersion: the only version the runtime uses to identify and load an assembly. For strong-named assemblies, .NET Framework’s CLR requires an exact match, so every bump forces callers to add a binding redirect (.NET / .NET Core, by contrast, accepts a higher version automatically). To cut down on redirects, official guidance suggests reflecting only the major version in AssemblyVersion.
  • FileVersion (AssemblyFileVersion): visible only in Explorer’s file properties, with no effect on runtime behavior. It’s the recommended place to stamp in a CI build number.
  • InformationalVersion: a free-form, human-facing string. Used to record a semver-formatted package version or the source commit hash.

In practice, then, the workable three-tier setup is: declare compatibility through the package/product version (semver), reflect only the major version in AssemblyVersion, and track builds with FileVersion.

6. How to Assign Version Numbers — Semver Only Works Once You Have a “Definition”

Semantic versioning (semver) fits in three lines: bump MAJOR for an incompatible change, MINOR for a backward-compatible feature addition, and PATCH for a backward-compatible bug fix.5

What’s easy to overlook is that the semver spec’s very first requirement is that software using semver must declare a public API.5 Without declaring what counts as the public API, there’s no criterion for what an “incompatible change” even is, and whether to bump the major version comes down to whoever’s mood that day. In most places where semver isn’t actually working, the problem isn’t how the numbers are assigned — it’s that this declaration got skipped.

A realistic operating model for a DLL distributed internally looks like this.

  1. Declare the scope of the public API — for a native DLL, that’s the exported functions and public headers; for COM, the IDL/type library; for .NET, public types and members. State explicitly that “anything outside this is an internal implementation detail that can change without notice.”
  2. Adopt a definition of a breaking change — put this article’s decision tables from Chapters 3 and 5, along with .NET’s change rules2, into your repository as “our own definition.”
  3. Automate the check — for .NET, tools like Package Validation and ApiCompat can mechanically verify binary compatibility against the previous version.11 This eliminates the “probably fine” judgment call during review.
  4. Add a compatibility field to the release notes — spell out, every single time, one of three values: no rebuild needed / rebuild recommended / breaking change included. This is a mechanism for putting the answer to “can we just swap in the new DLL?” in writing before anyone even has to ask.

7. The Procedure for When Breaking Compatibility Is Unavoidable

When a change that the decision table flags as breaking turns out to be unavoidable, proceed by offering both versions side by side rather than swapping one out for the other.

  1. Offer old and new side by side — for COM, that’s adding IFoo2 while leaving IFoo in place (Chapter 4). For a native DLL, add a new function (FooEx) or let a differently named new DLL coexist with the old one. For .NET, ship the change as a new package with a bumped major version, and keep the old major version alive for bug fixes only.
  2. Set a deprecation period — in .NET, the [Obsolete] attribute can raise a compile-time warning. For native/COM code, declare it via header comments and release notes, and state the planned removal date explicitly. The key is committing to an actual date, not a vague “we’ll remove it someday.”
  3. Take inventory of callers — build a list of who’s still calling the old API by searching in-house source code, checking installer distribution records, and for COM, checking registry reference usage. If you find a binary you can’t rebuild — a tool built by someone who’s since left, or a third-party app — either extend the old API’s lifetime for that case specifically or bridge the gap with a wrapper.
  4. Remove the old API — only after the inventory confirms zero remaining callers, remove it and bump the major version.

This procedure is costly. Which is exactly why, paradoxically, designing a small API with the decision table in mind from the very first release — since anything you never publish carries no compatibility obligation at all — is the single biggest compatibility safeguard you have.

8. Summary

  • Think about compatibility in three layers: binary, source, and behavioral. Behavioral compatibility can break even without a rebuild.3
  • For native DLLs, adding is safe; changing an existing export, signature, or struct layout is breaking. Give structs a cbSize field to leave room for extension.6
  • A COM interface is immutable once published. Add changes as a new interface with a new IID (IFoo2) and let callers distinguish via QueryInterface.147 Enforce this especially strictly whenever early-bound VB6/VBA clients are involved.
  • For .NET, you can judge mechanically using the official compatibility change rules. Watch for “apparently safe” changes — adding virtual, renaming a parameter, sealing a type — that are classified as breaking.2
  • A realistic three-tier setup: AssemblyVersion carries only the major version, FileVersion tracks the build, and semver declares compatibility.10
  • Semver only works once you declare a public API and a definition of a breaking change.5 Adopt the decision table as that definition and check it automatically with tools like Package Validation.11
  • When you do break something, go side-by-side offering → deprecation period → inventory → removal. Never swapping things out all at once is what protects the old EXE still running at a client site.

Komura Software LLC handles compatibility design for DLLs, COM components, and .NET libraries referenced by other systems; taking inventory of public APIs and putting versioning policy in place; and designing and implementing extensions — the IFoo2 pattern, side-by-side offering — that don’t break existing clients.

References

  1. Microsoft Learn, Interface Design Rules. On COM objects’ interfaces needing a unique IID, and on no part of an interface’s definition being allowed to change once created and published (immutability).  2 3 4 5

  2. Microsoft Learn, Change rules for compatibility (.NET). On .NET API changes being classified as allowed, forbidden, or needs-judgment; removing or renaming public types/members, signature changes, parameter renames, adding/removing virtual, sealing, and changing constant/enum values all being classified as forbidden (breaking); adding a member to an interface needing judgment; and library authors being able to use the rules as their own library’s evaluation criteria.  2 3 4 5

  3. Microsoft Learn, Breaking changes (.NET library guidance). On breaking changes being classified as source-breaking, behavior-breaking, or binary-breaking, and on binary-breaking changes causing an assembly compiled against the old version to fail at run time with a MissingMethodException or similar.  2 3

  4. Microsoft Learn, Interface Pointers and Interfaces. On COM interfaces being immutable, and how adding or removing a method or changing its semantics means creating a new interface rather than a new version of the old one, with the IID uniquely defining the contract.  2 3

  5. semver.org, Semantic Versioning 2.0.0. On bumping MAJOR for incompatible API changes, MINOR for backward-compatible feature additions, and PATCH for backward-compatible bug fixes; software using semver being required to declare a public API; and a backward-incompatible change to the public API always requiring a MAJOR version bump.  2 3 4

  6. Microsoft Learn, NOTIFYICONDATAW structure (shellapi.h). On setting the struct size in the cbSize member, the struct having been extended across generations, and setting the appropriate cbSize value keeping compatibility with older versions of Shell32.dll.  2

  7. Microsoft Learn, The Versioning Theory for RPC and COM. On creating a new interface being the best way to extend functionality in COM, a new interface that inherits from the old one being equivalent to a minor version bump, changes to existing methods or types requiring an entirely new non-inheriting interface, and QueryInterface letting callers check what’s supported.  2

  8. Microsoft Learn, COM Registry Keys. On CLSID being the GUID that identifies a COM class, ProgID being a human-readable string mapped to a CLSID without a uniqueness guarantee, the version-independent ProgID being mapped to the latest class version via CurVer, and the Interface key registering IIDs.  2

  9. Microsoft Learn, OLE programmatic identifiers, late binding, and early binding (Project). On early binding via a project reference being recommended in VBA, late binding (CreateObject/ProgID) not showing members while writing code and running slower, and early binding requiring a reference to the target object library. 

  10. Microsoft Learn, Versioning (.NET library guidance). On AssemblyVersion being used by the runtime for loading and requiring an exact match for strong-named assemblies under .NET Framework, the suggestion to include only the major version in AssemblyVersion, FileVersion being for Windows display purposes with no effect on runtime behavior, InformationalVersion recording additional version information, and semver 2.0.0 being recommended for NuGet package versions.  2

  11. Microsoft Learn, NuGet package compatibility rules. On the need to avoid binary-breaking changes, Package Validation and ApiCompat tools being able to automatically detect compatibility against a baseline version, and AssemblyVersion never being allowed to decrease between releases.  2

Recent articles sharing the same tags. Deepen your understanding with closely related topics.

These topic pages place the article in a broader service and decision context.

This article connects naturally to the following service pages.

Frequently Asked Questions

Common questions about the topic of this article.

If I only add a function to a DLL, do callers need to be rebuilt?
As a rule, no — if you only add an exported function, existing callers keep working as-is, because import resolution still succeeds as long as you don't change an existing function's name, signature, calling convention, or export ordinal. That said, if you add a member to a struct that the caller allocates and passes in, or change the meaning of an existing function's return value or error code, behavioral compatibility can break even though nothing forces a rebuild. The baseline rule is: adding a function is safe, changing an existing signature is breaking.
Why is it forbidden to add a method to a COM interface after it has shipped?
Because under the COM specification, an interface is immutable once it has been published. An interface's real substance is a binary layout contract — the vtable, an ordered list of function pointers — and inserting, removing, or reordering methods means an old binary calls a different method at the slot position it baked in at compile time. Appending to the end doesn't move existing slots, but it introduces a new hazard: a new client can grab an old component instance and assume the appended method is present, then call a slot that doesn't actually exist there. That's why even an append under the same IID isn't allowed. When you want to add functionality, you add a new interface with a new IID (IFoo2) and leave the existing IFoo untouched. Callers can then safely determine, at run time via QueryInterface, whether they're talking to the old interface or the new one.
How should I use .NET's AssemblyVersion, FileVersion, and InformationalVersion differently?
AssemblyVersion is the only version the runtime uses to identify and load an assembly; for strong-named assemblies, .NET Framework's CLR demands an exact match, so bumping it forces callers to add a binding redirect. That's why official guidance suggests reflecting only the major version in AssemblyVersion. FileVersion only shows up in Explorer's file properties and has no effect on runtime behavior, which makes it a good place to stamp in a CI build number. InformationalVersion is a free-form, human-facing string, used to record a semver-formatted version or a commit hash.
Does adopting semantic versioning (semver) solve compatibility problems by itself?
No, semver alone doesn't solve it. Semver is the convention of bumping the major version whenever you make a backward-incompatible change, but it presupposes that you've already declared what counts as your public API and what counts as a breaking change. Slap version numbers on releases without that definition, and the judgment call varies from person to person and the scheme stops working. Semver only becomes meaningful once you adopt something like this article's decision table (for native DLLs) or Microsoft's compatibility change rules (for .NET) as your own definition of a breaking change, and bake that definition into your release process.

Author Profile

Profile page for the article author.

Go Komura

Representative of KomuraSoft LLC

Focused on Windows software development, technical consulting, and investigations into failures that are difficult to reproduce.

Back to the Blog