Introduction
Part 1 covered the adoption decision: the difference between an external DLL and an asmdef, sharing code with a C# game server, reducing Unity's daily compilation scope, running standard .NET tests, and keeping Unity dependencies out of the core domain.
This second part focuses on implementation and operations. It shows how to build a managed DLL with an external .NET SDK and integrate it safely into Unity.
The main topics are:
- When a DLL can use newer C# syntax than Unity compiles directly.
- The different roles of
netstandard2.1andLangVersion. - Pinning the .NET SDK with
global.json. - Building a public API that still passes warnings-as-errors and XML documentation checks.
- Referencing the same DLL from Unity and a C# server.
- Managing Plugin Importer settings and
.metafiles. - IL2CPP, AOT, managed stripping, reflection, and generic-code restrictions.
- Portable PDB, Source Link, commit hashes, and reproducible debugging.
- Treating the DLL, PDB, XML documentation,
.meta, license, and linker configuration as one deliverable.
The examples are based on Unity 6.5 as of July 2026. The goal is not to bypass Unity's runtime restrictions. The goal is to use an external build toolchain while staying inside the runtime and API surface Unity can actually execute.
The Main Rule: A Newer Compiler Does Not Give Unity a Newer .NET Runtime
Building a DLL with an external .NET SDK can let you use C# syntax that the Unity Editor does not accept as project source. Three separate layers must still be considered:
- The C# compiler that understands the source syntax.
- The Unity runtime that executes the generated IL.
- The Base Class Library APIs called by the DLL.
Setting LangVersion to C# 12 does not turn Unity into a .NET 10 runtime. The DLL imported into Unity must still target a compatible API surface, typically netstandard2.1 for the Unity version considered in this article.
A safe design also keeps newer syntax inside the implementation and exposes conservative public types: ordinary classes, structs, interfaces, arrays, and other APIs that Unity-side source can consume without depending on the new syntax.
You Can Use Newer C# Syntax Inside the DLL
The important distinction is straightforward:
If an external compiler can compile the source and the resulting assembly stays within Unity's runtime and API compatibility limits, Unity can often use the DLL even when the Editor could not compile that source directly.
Unity 6.5 documents C# 9.0 as the language version used for project scripts, with some feature restrictions. The exact status by Unity version is documented in Unity's C# compiler and language version reference.
The source of an imported DLL is not parsed by Unity's C# compiler. For example, a recent Roslyn compiler can compile a C# 12 primary constructor into IL:
namespace Shared.GameRules;
// Keep the C# 12 primary constructor inside the DLL implementation.
internal sealed class StaminaRuleCore(int maxValue, int recoverySeconds)
{
public int MaxValue => maxValue;
public int RecoverySeconds => recoverySeconds;
public int Clamp(int value) => Math.Clamp(value, 0, maxValue);
}
Unity loads the compiled assembly rather than parsing this source file.
There is no need to make this type public. The implementation later in this article keeps the primary-constructor type internal and exposes a conventional StaminaRule facade to Unity and the server. That keeps the architectural rule consistent: newer syntax is an implementation detail, while the public boundary remains conservative.
This does not mean that every new C# or .NET feature works automatically.
Three Compatibility Layers
At minimum, verify these three layers:
- Compiler compatibility: Does the selected Roslyn compiler understand the source syntax?
- Runtime compatibility: Can Unity's scripting backend execute the resulting IL and metadata?
- API compatibility: Are all referenced framework APIs available in Unity's supported profile and on every target platform?
Some language features, such as file-scoped namespaces and raw string literals, mostly compile down to forms that older runtimes already understand.
Other features require new runtime capabilities, metadata, attributes, or library types. Microsoft also notes that some language features introduced from C# 8 onward depend on CLR capabilities or types associated with newer .NET implementations.
Unity 6.5 supports managed plug-ins targeting compatible .NET Standard or .NET Framework profiles. Unity's API compatibility documentation states that assemblies targeting .NET Core are not the intended input for this workflow.
Therefore, this is not an appropriate Unity plug-in target:
<TargetFramework>net10.0</TargetFramework>
Do not expect to import that server-targeted assembly directly into Unity.
Use a compatibility target for the shared library instead:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<LangVersion>12.0</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Deterministic>true</Deterministic>
</PropertyGroup>
</Project>
TargetFramework defines the available API surface and runtime compatibility. LangVersion defines the source syntax accepted by the compiler. Treating them as separate decisions is essential.
Confirm Unity's API Compatibility Level
Successfully compiling for netstandard2.1 does not prove that every Unity target will behave identically.
Pin and test the following assumptions for each artifact:
- The supported Unity version or version range.
- The Player Settings
API Compatibility Level. - The actual platforms: Android, iOS, WebGL, desktop, consoles, and others.
- Every Base Class Library API used inside the DLL.
- The scripting backend, AOT restrictions, and managed stripping configuration.
An API can exist in the chosen target framework and still require validation on the actual Unity backend or platform. If the SDK must support several Unity versions, consider targeting the oldest supported API surface, publishing different artifacts per compatibility group, or using multiple target frameworks where that genuinely helps.
Use Unity's API compatibility levels for .NET as the reference for the project setting and supported profiles.
Avoid this in a shared library:
<LangVersion>latest</LangVersion>
latest can change behavior merely because a developer machine or CI image installs a newer SDK. Use an explicit version such as 12.0, and pin the SDK itself with global.json.
Features That Are Usually Easier, and Features That Need More Care
Features that often compile into existing IL and metadata patterns include:
- File-scoped namespaces.
- Global using directives.
- Raw string literals.
- Newer pattern matching syntax.
- Primary constructors.
- Lambda and type-inference syntax improvements.
- Compile-time code generation helpers.
Features and libraries that deserve more careful validation include:
-
requiredmembers. -
initaccessors. - Features that require newer attribute types.
- Default interface methods and other runtime-dependent features.
-
System.Reflection.Emit. - APIs specific to a newer .NET runtime.
- Libraries that assume CoreCLR or NativeAOT behavior.
- Reflection-heavy serializers and DI containers.
- Runtime proxy or code generation.
The risk increases when the public API exposes metadata or construction requirements that Unity-side code must understand.
A practical rule is:
Use newer C# syntax inside the DLL, but keep the types, parameters, return values, and attributes exposed to Unity conservative.
For example, the internal code may use a primary constructor and new pattern syntax, while the public API uses ordinary classes, structs, interfaces, Task, arrays, and IReadOnlyList<T>.
Be cautious about exposing required, init, or new attribute-dependent initialization patterns on public DTOs that Unity code must construct or serialize. Also distinguish between types used as a callable API and types expected to participate in Unity serialization.
global using is usually compatible at the IL level, but excessive use can make source dependencies less visible. Keep its use consistent through team conventions and analyzers.
Do Not Make New Syntax the Only Reason for DLL Packaging
If the sole goal is to use a C# 12 syntax feature, the operational cost can exceed the benefit:
- Navigating between Unity source and external library source.
- Running a separate build.
- Keeping DLL and PDB files synchronized.
- Maintaining CI compatibility tests.
- Verifying IL2CPP and real devices.
Treat newer syntax as an additional benefit of a library that already has stronger reasons to exist: server sharing, independent tests, strict architecture, or binary distribution.
Use the Standard .NET SDK Toolchain
An SDK-style project outside Unity can use the normal .NET development toolchain:
- Shared settings through
Directory.Build.props. - Warnings as errors.
- Nullable Reference Types.
- Roslyn analyzers.
- Source Generators.
-
dotnet test. -
dotnet format. -
dotnet pack. - NuGet dependency management.
- Deterministic builds.
- Source Link.
- Multiple target frameworks.
- CI caching.
Unity can also use analyzers and additional compiler arguments, but its exact behavior depends on the Unity version and generated project setup. A separate library can follow the same rules used by the server or platform team.
Pin the SDK with global.json
Once the compiler is external, prevent developers and CI from silently using different Roslyn versions.
Place a global.json near the repository root:
{
"sdk": {
"version": "10.0.301",
"rollForward": "latestPatch",
"allowPrerelease": false
}
}
10.0.301 is an example for this article. It is not a required SDK for Unity DLL development. Replace it with an SDK version installed and supported by your team and CI.
latestPatch permits patch updates within the same feature band while avoiding an unintended feature-band or major-version jump.
Print the environment at the start of CI:
dotnet --version
dotnet --info
Add the commit hash to InformationalVersion so that both build logs and the produced assembly identify the compiler environment and source revision.
If the library should treat all warnings as errors, use:
<PropertyGroup>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
If only nullable warnings should fail the build, remove TreatWarningsAsErrors and configure WarningsAsErrors for the desired warning IDs or categories. Avoid showing both approaches without explaining which policy is intended.
Source Generators can also run entirely in the external build, with Unity receiving only the resulting DLL. This can be easier to reproduce than installing the generator into Unity's compiler pipeline.
Generated code is still subject to Unity's runtime, API compatibility, and IL2CPP restrictions. External generation controls the toolchain; it does not bypass the platform.
Implementation: One DLL Used by Unity and a .NET Server
The following is a small but complete layout.
Directory Structure
GameRepository/
├── global.json
├── Shared/
│ ├── Shared.GameRules/
│ │ ├── Shared.GameRules.csproj
│ │ └── StaminaRule.cs
│ └── Shared.GameRules.Tests/
├── Server/
│ └── Game.Server/
└── UnityClient/
└── Assets/
└── Plugins/
└── Company/
├── Shared.GameRules.dll
├── Shared.GameRules.dll.meta
└── Shared.GameRules.pdb
Treat more than the DLL itself as the artifact. A production package may include the DLL, PDB, XML documentation, license, version metadata, and, where necessary, a link.xml file.
Shared Library csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<LangVersion>12.0</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<Deterministic>true</Deterministic>
</PropertyGroup>
</Project>
With GenerateDocumentationFile and TreatWarningsAsErrors enabled together, missing XML documentation for public types and members can make CS1591 fail the build. Add documentation to the public API rather than suppressing the warning in a reusable SDK.
A short tutorial could disable CS1591, but an internal shared library or distributed SDK benefits from shipping API documentation with the artifact.
Keep C# 12 in the Internal Implementation
using System;
namespace Shared.GameRules;
/// <summary>
/// Defines the maximum stamina and time-based recovery rule.
/// </summary>
public sealed class StaminaRule
{
private readonly StaminaRuleCore _core;
/// <summary>
/// Initializes a stamina rule.
/// </summary>
/// <param name="maxValue">The maximum stamina value.</param>
/// <param name="recoverySeconds">
/// The number of seconds required to recover one point.
/// </param>
public StaminaRule(int maxValue, int recoverySeconds)
{
_core = new StaminaRuleCore(maxValue, recoverySeconds);
}
/// <summary>
/// Gets the maximum stamina value.
/// </summary>
public int MaxValue => _core.MaxValue;
/// <summary>
/// Gets the seconds required to recover one point.
/// </summary>
public int RecoverySeconds => _core.RecoverySeconds;
/// <summary>
/// Clamps a value to the inclusive range from zero to the maximum.
/// </summary>
/// <param name="value">The value to clamp.</param>
/// <returns>The clamped value.</returns>
public int Clamp(int value)
{
return _core.Clamp(value);
}
/// <summary>
/// Applies a non-negative recovery amount without exceeding the maximum.
/// </summary>
/// <param name="currentValue">The stamina value before recovery.</param>
/// <param name="recoveredAmount">The non-negative amount to apply.</param>
/// <returns>The stamina value after recovery.</returns>
public int ApplyRecovery(int currentValue, int recoveredAmount)
{
return _core.ApplyRecovery(currentValue, recoveredAmount);
}
/// <summary>
/// Calculates the stamina recovered during the specified UTC interval.
/// </summary>
/// <param name="previous">The previous confirmed recovery time in UTC.</param>
/// <param name="current">The current calculation time in UTC.</param>
/// <returns>The amount recovered during the interval.</returns>
public int CalculateRecoveredAmount(
DateTimeOffset previous,
DateTimeOffset current)
{
return _core.CalculateRecoveredAmount(previous, current);
}
}
// The primary constructor is an internal implementation detail.
internal sealed class StaminaRuleCore(
int maxValue,
int recoverySeconds)
{
public int MaxValue { get; } =
ValidatePositive(maxValue, nameof(maxValue));
public int RecoverySeconds { get; } =
ValidatePositive(recoverySeconds, nameof(recoverySeconds));
public int Clamp(int value)
{
return Math.Clamp(value, 0, MaxValue);
}
public int ApplyRecovery(int currentValue, int recoveredAmount)
{
if (recoveredAmount < 0)
{
throw new ArgumentOutOfRangeException(
nameof(recoveredAmount),
recoveredAmount,
"The recovered amount must be zero or greater.");
}
var normalizedCurrent = Clamp(currentValue);
var finalValue = (long)normalizedCurrent + recoveredAmount;
return finalValue >= MaxValue
? MaxValue
: (int)finalValue;
}
public int CalculateRecoveredAmount(
DateTimeOffset previous,
DateTimeOffset current)
{
if (current <= previous)
{
return 0;
}
var elapsed = current - previous;
var elapsedSeconds =
elapsed.Ticks / TimeSpan.TicksPerSecond;
var recoveredAmount =
elapsedSeconds / RecoverySeconds;
return recoveredAmount >= int.MaxValue
? int.MaxValue
: (int)recoveredAmount;
}
private static int ValidatePositive(
int value,
string parameterName)
{
return value > 0
? value
: throw new ArgumentOutOfRangeException(
parameterName,
value,
"The value must be greater than zero.");
}
}
The public StaminaRule uses a normal constructor and ordinary methods. Unity-side C# source does not need to understand the C# 12 primary constructor.
The method is named CalculateRecoveredAmount because it returns the amount recovered during an interval, not the final stamina value. Apply the result through the separate public API:
var finalValue = rule.ApplyRecovery(
currentValue,
recoveredAmount);
ApplyRecovery normalizes the current value, widens the addition to long, and saturates at MaxValue. It avoids overflowing an int before calling Clamp.
The time calculation also avoids TimeSpan.TotalSeconds and its double representation. It converts ticks to whole seconds explicitly, making the rounding policy consistent across consumers.
Server Side
During development, reference the project directly from the server instead of manually copying the DLL:
<ItemGroup>
<ProjectReference Include="..\..\Shared\Shared.GameRules\Shared.GameRules.csproj" />
</ItemGroup>
The server can still target net10.0; only the shared project targets netstandard2.1.
Do not scatter direct clock reads across domain code. Inject a clock:
using System;
using Shared.GameRules;
internal interface IClock
{
DateTimeOffset UtcNow { get; }
}
internal sealed class SystemClock : IClock
{
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
}
internal sealed class StaminaRecoveryService
{
private readonly IClock _clock;
private readonly StaminaRule _rule;
public StaminaRecoveryService(IClock clock)
{
_clock = clock;
_rule = new StaminaRule(
maxValue: 100,
recoverySeconds: 300);
}
public int Calculate(DateTimeOffset lastRecoveredAt)
{
return _rule.CalculateRecoveredAmount(
lastRecoveredAt,
_clock.UtcNow);
}
}
Even in a short sample, a clock abstraction makes fixed-time tests possible. The server uses UTC as the authoritative source and does not trust the client device clock for the final decision.
Unity Side
Place the built assembly under a stable path such as Assets/Plugins/Company:
using Shared.GameRules;
using UnityEngine;
public sealed class StaminaPresenter : MonoBehaviour
{
[SerializeField] private int maxValue = 100;
[SerializeField] private int recoverySeconds = 300;
private StaminaRule _rule = null!;
private void Awake()
{
_rule = new StaminaRule(maxValue, recoverySeconds);
}
public int Normalize(int value)
{
return _rule.Clamp(value);
}
}
Unity lifecycle and Inspector values remain in a thin adapter; the calculation lives in the shared DLL.
The field uses null! because Unity initializes it in Awake, while a nullable-enabled compiler cannot infer that lifecycle guarantee. If the design permits access before Awake, use a nullable field, a defensive exception, or another initialization pattern consistent with the team's Unity conventions.
Automate DLL, PDB, and XML Copying
Manual copying makes it easy to commit an old DLL with a new source revision. Automate at least the basic flow.
PowerShell example:
$ErrorActionPreference = "Stop"
dotnet --version
dotnet build `
".\Shared\Shared.GameRules\Shared.GameRules.csproj" `
-c Release
dotnet test `
".\Shared\Shared.GameRules.Tests\Shared.GameRules.Tests.csproj" `
-c Release
$source = ".\Shared\Shared.GameRules\bin\Release\netstandard2.1"
$destination = ".\UnityClient\Assets\Plugins\Company"
New-Item -ItemType Directory -Force -Path $destination | Out-Null
Copy-Item "$source\Shared.GameRules.dll" $destination -Force
Copy-Item "$source\Shared.GameRules.pdb" $destination -Force
Copy-Item "$source\Shared.GameRules.xml" $destination -Force
This script intentionally does not overwrite the .meta file. Commit or package the configured Shared.GameRules.dll.meta, and replace only the DLL at the same path.
A CI pipeline can use this order:
dotnet --version / dotnet --info
dotnet restore
dotnet build
dotnet test
Copy DLL, PDB, and XML documentation into a Unity Package
Run Unity EditMode tests
Build the target Player
Run an IL2CPP smoke test on the target platform
A successful external build proves only that the .NET project compiles. It does not prove that Unity import, IL2CPP, managed stripping, or the final device works.
Unity Plugin Importer Settings
Unity treats a managed DLL as a plug-in asset. The main settings are managed through the Plugin Inspector.
Store Importer Settings in .meta
Auto Referenced, target platforms, CPU, editor-only settings, and other Plugin Importer state are stored in Unity's .meta file, not inside the DLL.
If CI copies a DLL to a new path and lets Unity generate a fresh .meta every time, the import configuration is no longer reproducible.
A safer workflow is:
- Configure the Plugin Importer once in Unity.
- Commit or package the resulting
Shared.GameRules.dll.meta. - Replace only the DLL at the same path during updates.
- Include the configured
.metawhen distributing the package to a new project. - Review the platform settings for every platform-specific DLL.
The importer configuration is part of the deliverable, not local editor state.
Auto Referenced
When enabled, Unity assemblies can reference the DLL automatically. This is convenient for initial integration but broadens the dependency graph.
For a small project with one foundational DLL, leaving it enabled may be reasonable. As the number of libraries grows, disabling it and referencing the DLL only from selected asmdef files makes dependencies clearer.
A predefined assembly such as Assembly-CSharp cannot configure an explicit reference to a precompiled DLL. Code that needs a DLL with Auto Referenced disabled must live under an asmdef that references the DLL. If existing code remains in Assembly-CSharp, the DLL must remain auto-referenced.
Platform Settings
Select the platforms that should include the DLL: Editor, Windows, macOS, Android, iOS, WebGL, and others.
A pure netstandard2.1 library is often usable on all managed targets, but a DLL calling OS-specific APIs must be restricted.
If several platform-specific files share the same assembly name, ensure that only one is active for a given build target.
Override References on asmdef Files
For an asmdef that consumes a DLL whose Auto Referenced setting is disabled, enable Override References and add the DLL under Assembly References.
Once Override References is enabled, the asmdef explicitly lists the precompiled DLLs it references. Forgetting another dependency can therefore produce compilation errors.
Adopt the setting gradually on assemblies where narrowing the dependency graph has a real benefit.
IL2CPP and AOT Restrictions
A DLL that works in the Mono Editor is not guaranteed to work on iOS, consoles, WebGL, or IL2CPP Android.
Unity documents AOT restrictions, the lack of System.Reflection.Emit, stripping risks for code referenced only through reflection, and special considerations for serializers and generic code. See Unity's IL2CPP limitations.
Reflection and Managed Stripping
A string-based type lookup is difficult for static analysis to follow:
// Simplified example showing a stripping risk.
var type = Type.GetType(typeName)
?? throw new InvalidOperationException(
$"Type was not found: {typeName}");
var instance = Activator.CreateInstance(type);
This is a minimal illustration of the risk, not a complete production factory. It may work in the Editor while the type or constructor is removed from an IL2CPP Player. See Unity's Managed code stripping documentation.
Use Preserve or link.xml where necessary. There is an architectural detail, however: applying UnityEngine.Scripting.Preserve directly inside a Unity-independent Core DLL introduces a reference to UnityEngine.
If the Core DLL must remain a pure .NET library, prefer one of these approaches:
- Put the preservation rule in the Unity project's
link.xml. - Put Unity-specific preservation declarations in a Unity adapter assembly.
- Replace reflection with explicit static registration or generated code.
Example link.xml:
<linker>
<assembly fullname="Shared.GameRules">
<type fullname="Shared.GameRules.GeneratedRuleRegistry" preserve="all" />
</assembly>
</linker>
Do not preserve an entire large assembly without a reason. It can increase build size and IL2CPP build time. Reduce reflection and preserve the smallest necessary surface.
Runtime Code Generation
Be careful with libraries that depend on:
-
System.Reflection.Emit. -
DynamicMethod. - Runtime proxy generation.
- JIT-based serializers.
- Runtime compilation of expression trees.
- Dynamically generated DI interceptors.
They may work in the Mono Editor and fail in an AOT environment. Prefer libraries that provide an AOT mode, source generation, pre-registration, or static resolvers.
Generic Code
AOT compilation must know which concrete generic combinations are required.
Generic methods or types reached only through reflection are easy to miss. Possible mitigations include explicit references to representative combinations, generated registration code, or linker configuration.
Test the Final Target
A useful validation order is:
- Normal .NET tests.
- Unity Editor EditMode tests.
- Mono Development Build.
- IL2CPP Development Build.
- The real Android, iOS, WebGL, console, or desktop target.
- A Release Build using the production Managed Stripping Level.
"It compiles in Unity" is only the first checkpoint.
Preserve Debuggability
A common complaint about external DLLs is that exception stack traces no longer navigate to source lines.
Place the Portable PDB produced by the same build next to the DLL, and make the corresponding source revision available to developers.
Portable PDB mainly improves managed-code debugging and mapping managed exception stack traces to source. An IL2CPP Player's native crash analysis is a different pipeline. It can require Development Build or Script Debugging settings during investigation, IL2CPP-generated native symbols, and platform-specific symbol storage or upload. A managed PDB alone does not solve every Player crash.
Assets/Plugins/Company/
├── Shared.GameRules.dll
└── Shared.GameRules.pdb
Never update the DLL while leaving an old PDB, and do not combine artifacts from separate builds. Publish them together under one CI build number.
Useful practices for an internal library include:
- Configure Source Link so compatible IDEs and debuggers can retrieve the matching source.
- Put the commit hash in
InformationalVersion. - Log the library version during application startup.
- Prevent accidental mixing of Debug and Release artifacts.
- Define how NuGet/package versions, assembly versions, and file versions relate.
An exception report containing Shared.GameRules 2.3.1+abc1234 immediately identifies the source revision to inspect.
DLL packaging does not inherently make debugging impossible. Debugging becomes difficult when binary, symbol, source, and version relationships are not automated.
Final Validation Before Adoption or Release
Before using the article's configuration in a production project, verify at least the following in a minimal repository:
-
dotnet build -c Releasefor the shared library. -
dotnet test -c Releasefor the shared tests. - A server sample with the
ProjectReference. - Unity import after copying the DLL, PDB, and XML documentation.
- Compilation with DLL
Auto Referenceddisabled and asmdefOverride Referencesconfigured. - Unity EditMode tests.
- An IL2CPP Development Build for the target platform.
- A Release Build using a production-like Managed Stripping Level.
- Device smoke tests for reflection, serialization, and generic-code paths.
A successful external build does not validate Unity import, IL2CPP, stripping, or platform APIs. Automate as much of the full path as practical.
Implementation and Operations Decision Table
| Goal or situation | Decision |
|---|---|
| Use C# syntax newer than Unity compiles directly | Possible in an external DLL, but it should be a secondary benefit |
Import a net8.0 or net10.0 server DLL directly into Unity |
Do not; separate a Unity-compatible target |
Use LangVersion latest
|
Prefer an explicit numeric version for reproducibility |
| Expose new-language features throughout the public API | Keep them internal and use a conservative public surface |
| Use a Reflection.Emit-based library on iOS | Avoid it or find an AOT-compatible mode |
| Distribute the same DLL to several games | A versioned UPM package is a strong option |
| Ship Asset Store implementation as a binary | A DLL can work, but it remains decompilable |
| Share Unity-heavy code with a server | Split Core and Unity Adapter assemblies |
| Make Plugin Importer settings reproducible | Version the .meta file with the artifact |
Conclusion
The key practices for operating external C# DLLs safely in Unity are:
- Treat
LangVersionandTargetFrameworkas separate compatibility decisions. - Keep newer C# syntax inside the implementation and expose a conservative API.
- Target a Unity-compatible surface such as
netstandard2.1for the shared DLL. - Pin the .NET SDK with
global.jsonand avoidLangVersion latest. - If XML documentation and warnings-as-errors are enabled, document every public API.
- Package the DLL, PDB, XML documentation,
.meta, license, and linker settings together. - Control the Plugin Importer reference graph with asmdef files and
Override References. - Test the production IL2CPP backend, stripping level, and real platform instead of stopping at the Editor.
- Preserve debuggability with
InformationalVersion, commit hashes, Source Link, and synchronized symbols. - Never place secrets in a managed DLL because it can be decompiled.
External compilation gives you a newer C# compiler, not a newer Unity .NET runtime. Once that distinction is clear, you can bring newer syntax, normal .NET SDK tooling, analyzers, Source Generators, and CI practices into Unity library development without assuming that Unity's execution restrictions have disappeared.
A practical package structure is:
Company.Product.Core.dll
└ netstandard2.1, no Unity dependency,
newer syntax used internally.
Company.Product.Unity.dll
└ UnityEngine conversions, Components,
and Unity-specific behavior.
Company.Product.Editor.dll
└ Inspectors, importers, MenuItem commands,
and editor-only tooling.
The benefits outweigh the operational cost when the full path is automated: external build, test, Unity import, IL2CPP Player build, device verification, and versioned artifact publication.
References
- Unity Manual: Managed plug-ins
- Unity Manual: C# compiler and language version reference
- Unity Manual: API compatibility levels for .NET
- Unity Manual: Referencing assemblies
- Unity Manual: IL2CPP limitations
- Unity Manual: Managed code stripping
- Microsoft Learn: C# language versioning
- Microsoft Learn: global.json overview
- Microsoft Learn: Target frameworks in SDK-style projects
- Microsoft Learn: Primary constructors
- Microsoft Learn: Compiler Warning CS1591
Top comments (0)