
Daniel Rinaldi (TenaciousDan)Table of Contents: Introduction Assembly Definitions Recommended Folder...
If you've spent enough time in Unity, you know the pain of the compilation progress bar. You tweak a single line of code in an isolated UI script, hit save, and stare at the screen while Unity recompiles your entire project. That is the reality of living inside Assembly-CSharp.dll, Unity's default monolithic bucket where every script you write gets dumped and tangled together.
But as your game scales, this monolith doesn't just destroy your iteration speed; it ruins your architecture.
In this blog post, we are going to explore how breaking away from the monolith and adopting a modular assembly structure is the first step toward a scalable, maintainable codebase.
Assembly Definition files are Unity's equivalent to C# projects in the broader .NET ecosystem. They allow you to carve up your codebase into isolated, logical modules with strict, explicit dependencies.
Setting up Assembly Definitions is straightforward, but structuring them correctly upfront saves you from painful circular dependency errors down the road.
Here is how to organize, create, and configure your modules for a clean Unity project.
We already discussed initial folder structure earlier in the series where we made a folder structure that included a _Modules/ folder and under that folder we had a Default/ folder with its own _Runtime/ and Editor folder. Note that a module is just a term for a unit of code that groups together related functions, variables, classes, and logic. Let's talk about why we used that folder structure now.
So, Default/ here represents the module for code involving Unity's default Assembly-CSharp assembly and related assemblies (like Assembly-CSharp-Editor), these can be considered sub-modules. You will never see, nor will you have to create the default assembly in a Unity project as Unity creates these behind the scenes for you and self manages them. You should have noticed them already in your IDE when browsing the solution explorer. Unity will automatically include all scripts that are not part of an assembly as part of the default Assembly-CSharp assembly and also any of those scripts that are under a folder named Editor into its Assembly-CSharp-Editor assembly.
If we want our own assemblies we need to create an assembly definition file (.asmdef). Before we do this let's take a moment to discuss how we will organize the folder structure.
Each asmdef should be mapped to its own dedicated folder. Placing one .asmdef file in a folder automatically binds every script within that folder (and its subfolders) to that specific assembly. Keep in mind though that we also need to create an asmdef for editor-only code, simply creating an Editor/ folder is not enough anymore, we need to explicitly create one if we have editor-only code that is related to our custom assembly.
Using the same structure as we did for the Default/ module folder works nicely for any other assembly. So essentially when creating a new module and its associated submodules (there's always at least one) we should structure our folder hierarchy it like so:
[PROJECT_ROOT]/
└── Assets/
└── _Project/
└── _Modules/
├── Default/
| ├── _Runtime/
| └── Editor/
└── MyCustomModule/
├── _Runtime/
| └── Project.MyCustomModule.asmdef
└── Editor/
└── ProjectEditor.MyCustomModule.asmdef
Doing it this way gives us a scalable method to easily add new modules and sub modules in the future. It also ensures the following:
.asmdef is constrained exclusively to the Editor platform, keeping UnityEditor namespaces out of your standalone runtime builds entirely.Assets/Editor junk drawer.Ok, so to create a .asmdef file is very simple.
Create > Scripting > Assembly Definition (Ignore Assembly Definition Reference, we'll discuss that later).Project.MyCustomModule). I also like to use a suffix appended to the root namespace for editor sub-modules (e.g, ProjectEditor.MyCustomModule)..asmdef file in the Project Explorer and use the Inspector window to configure its properties and dependencies (Assembly Definition References).Key Inspector Configurations:
| Setting | Purpose | Recommended Configuration |
| --- | --- | --- |
| Name | Defines the output .dll name and internal assembly ID. | Match your namespace convention (e.g, Project.MyCustomModule). |
| Allow 'unsafe' Code | Permits C# unsafe pointer context within this specific module. | Leave Unchecked unless writing low-level NativeArray/pointer code. |
| Auto Referenced | Controls whether the default Assembly-CSharp automatically references this assembly. | You should usually just keep this checked unless this is a truly isolated assembly that default game module logic should not access. |
| No Engine References | Strips out the implicit dependencies on UnityEngine and UnityEditor. | Check this only for pure C# domains (like raw math libraries or shared server logic) to strictly enforce engine independence. |
| Override References & Assembly References | Unlocks the ability to reference pre-compiled .dll files instead of just other .asmdef files. | Check only if this module needs to talk to a third-party, pre-compiled plugin, then add the specific .dll to the Assembly References list. |
| Root Namespace | Tells your IDE (Rider/Visual Studio) which namespace to auto-generate when creating new scripts in this folder. | Match your Name setting (e.g, Project.MyCustomModule). |
| Use GUIDs | Links your assembly references using their .meta file GUIDs instead of their string names. | Always Check. If you rename an .asmdef later, checking this ensures all dependencies stay intact. |
| Assembly Definition References | The dependencies of this asmdef. Explicitly declares which other .asmdef modules this assembly can access and call code from. | Reference only the essential modules that this module depends on to function (e.g, Editor asmdefs depend on their runtime counterparts). |
| Platforms | Limits assembly compilation to specific build targets (e.g., Editor, Standalone, iOS). | Set this to Any Platform for game logic; check the Editor checkbox only for editor tooling assemblies. |
| Define Constraints | Compiles the assembly only if specific #define symbols are set in Player Settings. | This is a more advanced setting that you'll likely not touch. Useful for optional integrations (e.g., ENABLE_INPUT_SYSTEM or UNITASK_SUPPORT). |
| Version Defines | Conditionally adds custom #define compiler flags based on installed UPM package versions or Unity Engine releases. | Another advanced feature. Essential for cross-package compatibility (e.g, set define to USING_NEW_INPUT if resource com.unity.inputsystem version is >= 1.0.0). |
Wiring Tip: The Editor assembly should almost always reference its corresponding _Runtime assembly so your custom drawers and tools can access the underlying data structures. The _Runtime assembly must never reference the Editor assembly.
Circular Dependencies: If Assembly A references Assembly B, Assembly B cannot reference Assembly A. C# enforces strict directed acyclic graphs (DAGs) when it comes to assembly definition references. If you hit a circular dependency, it’s a architectural sign that you need to decouple the two using C# interfaces (see my other post on Loose Coupling & Interfaces) or move shared logic down into a Core assembly.
Knowing how to configure an .asmdef is only half the battle. The real architectural challenge is knowing when to draw the boundary.
If you split your project too little, you are still living in the monolith. If you split it too much, you end up in "Unity Microservices Hell," where opening your IDE takes forever and you have to manage 50 tiny assembly reference lists just to program a simple feature.
Here are some practical heuristics for when you should (and shouldn't) create a new Assembly Definition.
.asmdef.Project.Inventory assembly for inventory related logic (Logic Layer). You then create UI logic scripts that are specifically related to your Inventory systems (Presentation Layer). Because the presentation layer can be coded in a way that it just depends on the logic layer and the logic layer should not depend on the presentation layer, you now have a valid case for separating the presentation layer into its own assembly (Project.Inventory.UI).Core assembly at a "dead end" in your dependency chain (see image below).Here is a basic example of an directed acyclic graph (DAG) setup showing some assembly definitions and their dependencies.
Project.Weapons.Swords.asmdef and Project.Weapons.Bows.asmdef. If systems are highly cohesive and almost always change together, they belong in the same assembly (Project.Weapons).Assembly-CSharp until the core gameplay loop and the fun is found and the natural domain boundaries reveal themselves. Afterwards go step by step, one assembly at a time. It's fine if you start with a big existing monolith and progress to separate it over time. Maybe you have some very generic code that is battle tested and game agnostic and you carry from project to project in a contained assembly, if so then you can use it in your prototyping or game jam project without any worries.If you are migrating an existing monolith to a modular setup, do not try to do it all at once. Unity will flood you with thousands of compiler errors. Instead, extract from the bottom up:
_Modules/Core/_Runtime/ folder and create the .asmdef file in that folder. Do the same for editor code related to those same files you are moving but put them in a _Modules/Core/Editor/ folder and create the associated .asmdef for it.Assembly-CSharp (which is auto-referenced) complain about missing namespaces. Add your using statements, verify the game compiles, and commit to source control.Audio or Input).Key insight: Start by extracting the code that is depended upon, rather than the code that does the depending
When you operate out of the default Assembly-CSharp.dll, Unity treats your entire project as a single, indivisible chunk of logic. Change a typo in a UI tooltip? Unity recompiles your procedural generation algorithms, your save systems, and your enemy AI before you can finally press Play.
Assembly Definitions break this monolith into isolated chunks, allowing Unity to perform partial recompilation.
When you modify a script inside an .asmdef, Unity's compiler looks at your dependency graph and asks: "Who relies on this?" It only recompiles the modified assembly and any assemblies higher up the chain that depend on it.
Let's look at this in practice using a typical setup:
Project.Core
Project.Inventory (depends on Core)Project.Inventory.UI (depends on Inventory)Scenario A: Tweaking the UI
You adjust the color-changing logic in InventorySlotUI.cs (inside Project.Inventory.UI).
Project.Inventory.UI. Project.Inventory and Project.Core are completely ignored.Scenario B: Modifying a Core Interface
You change a method signature inside Project.Core.
Project.Core. Because Project.Inventory depends on Core, it must also recompile to ensure it still matches the interface. Because Project.Inventory.UI depends on Inventory, it re-evaluates it as well.This dependency ripple is exactly why drawing clean architectural boundaries matters. To get the fastest compile times, you want your most frequently changing code at the end of the dependency chain (leaf nodes), and your most stable code at the start (root nodes).
UI layouts, game feel tweaks, weapon balancing, and audio hooks change constantly. Keep them in top-level assemblies. Math utilities, object pooling, and foundational interfaces and data structures rarely change once they are proven stable. Push them towards your Core layer.
More often than not you'll run into a scenario like this:
You have to create a "Character Sheet" screen that needs to display equipped items (Inventory assembly) and the player's current level (Stats assembly). If you have a strict Inventory.UI assembly and a Stats.UI assembly, neither can easily manage a unified screen without creating a new, third assembly just to bridge them.
Solution 1 - The Glue Layer:
You keep your pure, single-feature UI isolated (e.g, Inventory.UI handles the drag-and-drop backpack grid perfectly). But for cross-domain screens, you create a top-level assembly like Game.UI which acts as a "glue" layer. For smaller projects, this can be your default Assembly-CSharp as it already usually auto references all other assemblies.
The Glue Layer assembly explicitly references Inventory, Stats, and your core UI assemblies. The Character Sheet lives here. It acts as the visual glue layer, reading from both domains and piping the data into the screen.
Solution 2 - The Contract Pattern:
If you absolutely refuse to make a top-level glue assembly, you have to invert the dependencies using interfaces.
You create a shared Core.Contracts assembly definition that contains IEquipment and IStats interfaces. Character.UI assembly (where CharacterSheetUI.cs resides) references only Core.Contracts. It knows how to draw stats and items, but it has no idea where they come from. Your Inventory and Stats modules implement those interfaces.
You can then use dependency injection to wire them together at runtime via their interfaces (see my other blog post on Loose Coupling & Interfaces).
This is the exact moment where architecture stops being about "perfect rules" and starts being about pragmatism.
In a massive AAA project, Solution 2 (Contract Pattern) is required so that 50+ UI programmers don't step on each other's toes. But for a solo developer or a small team, Solution 1 (A top-level Game.UI glue layer) is vastly faster to write, easier to debug, and completely acceptable.
Transitioning a project to use Assembly Definitions can feel like eating your architectural vegetables. It requires upfront planning, strict discipline regarding dependencies, and a fundamental shift in how you think about your codebase's boundaries.
But the payoff is massive, especially in large scale projects.
By breaking the Assembly-CSharp monolith, you aren't just buying back hours of lost time previously spent staring at Unity's compilation progress bar. You are forcing yourself to write better, decoupled code. When systems physically cannot reference each other without generating a compiler error, spaghetti code becomes impossible by default. Your domains stay isolated, your interfaces remain clean, and your project becomes truly scalable.
Start small. Extract your core utilities first, isolate your UI, and slowly carve out your feature domains. Happy Coding :)