A Unity project rarely becomes difficult because one programmer wrote one bad class. It becomes difficult because hundreds of reasonable local decisions slowly remove the project's boundaries. After 16 years in game development, including shipping Eagle Flight Arcade as a Gameplay Programmer at Ubisoft Montreal in 2016 and maintaining products such as Touch Camera PRO, I have learned to treat changeability as a production feature. A project that works today but resists tomorrow's platform SDK, control scheme, or content request is already accumulating risk.
Key Takeaways
- Organize Unity projects around features and ownership, not only file types.
- Use assembly definitions to enforce a small number of meaningful boundaries.
- Keep platform APIs, global state, and scene references behind explicit seams.
- Automate architectural checks before conventions become optional.
- Refactor incrementally while delivering features instead of waiting for a rewrite.
What Actually Makes a Unity Project Hard to Change?
The real enemy is not the number of scripts. It is the distance between a requested change and its unexpected consequences. A menu adjustment should not require knowledge of the save format, platform achievements, character movement, and analytics. When it does, the project has lost separation between responsibilities. Unity makes this easy to miss because almost anything can reference a MonoBehaviour, find an object in a scene, or reach a static singleton.
I saw the importance of explicit responsibilities while working on Eagle Flight Arcade at Ubisoft Montreal in 2016. A VR game shipping across PSVR, Oculus Rift, and HTC Vive could not treat hardware assumptions as harmless details. The broader lesson applies to every Unity project. Platform code changes faster than core game rules, presentation changes faster than persistent data, and prototypes change faster than production infrastructure. If those rates of change are mixed together, every update becomes a project-wide negotiation.
I evaluate architecture by asking three questions. Which code owns this decision? What is allowed to depend on it? How would I replace it without editing unrelated features? If the answers are unclear, the system probably relies on accidental coupling. A public field in the Inspector may look convenient, but it still creates a dependency. The same is true for a static event, a Resources load, or a scene object discovered by name.
This does not mean every game needs enterprise-style abstraction. Small prototypes should remain small. The important distinction is whether a shortcut is contained. I am comfortable taking on debt when I can name it, locate it, and remove it. Unnamed debt spread across scenes, prefabs, and global services is what turns an ordinary feature request into a dangerous change.
Where Should You Draw Boundaries Inside a Unity Project?
I start with rates of change, not a favorite diagram. Code that changes for the same reason should usually live together. Code that changes for different reasons should communicate through a narrow contract. In a typical Unity project, I separate core rules, feature implementation, presentation, persistence, platform integration, and editor tooling. Those categories can become assemblies, but the conceptual boundary matters before the file structure does.
Features should own as much of their vertical path as practical. An inventory feature might contain its rules, data types, runtime controller, UI presenter, tests, and authoring tools. That is often easier to maintain than placing every view in one global UI folder, every data class in another, and every manager in a third. A developer investigating inventory behavior can then inspect one coherent area rather than tracing references across the whole repository.
Platform integrations belong at the outside edge. Steam, console services, mobile notifications, VR runtimes, and vendor-specific input should adapt external APIs into contracts understood by the game. Core rules should not know which SDK provided an identity, controller pose, purchase result, or storage location. This makes editor simulation easier and reduces the blast radius when a package changes during 2025 or 2026.
I also separate runtime code from authoring code. Custom inspectors, import processors, validation windows, and content generators are valuable, but a player build should never depend on an editor assembly. My Unity Asset Store work, especially Touch Camera PRO and tools such as Tutorial Engine and Level Designer, reinforced this discipline. A reusable package cannot assume that it owns the host project's scenes, initialization order, input setup, or folder conventions.
A useful boundary has a clear owner and a small public surface. If two modules constantly reach into each other's internal state, they are either one module pretending to be two or they need a better contract. Architecture is not the number of boxes in a diagram. It is the set of changes that each box can absorb independently.
How Should Scenes, Prefabs, and Assets Be Organized?
Unity architecture is not only C# architecture. Scenes, prefabs, ScriptableObjects, materials, and import settings carry dependencies that are invisible to a normal code review. I organize assets so that ownership is obvious. Shared assets should be intentionally shared, while feature-specific assets should stay with their feature. A folder called Common often becomes a landfill unless the team defines what qualifies for it.
I prefer small scenes with explicit purposes over one scene that silently owns the entire application. A bootstrap scene can establish long-lived services. Content scenes can provide environments or levels. UI can live in a dedicated scene when independent loading is useful. This is not a rule that every project must use additive loading. It is a way to make lifetime visible. The team should know which objects persist, who creates them, and who destroys them.
Prefab variants deserve similar restraint. They are excellent when a variant expresses a real product relationship, such as several enemies sharing a stable base. They become painful when inheritance is used merely to avoid duplicating a few values. Deep prefab chains obscure overrides and make edits hard to predict. I would rather duplicate a small presentation prefab than create an inheritance structure nobody feels safe modifying.
ScriptableObjects are useful for authored configuration and shared immutable data, but I avoid treating them as magical global variables. Runtime mutation can persist in the Editor in surprising ways, and shared references make tests order-dependent. If a ScriptableObject represents configuration, I keep runtime state elsewhere or create a runtime copy deliberately. The lifetime must be a design choice rather than an accident of Unity serialization.
Finally, naming should describe role, not implementation history. Names such as GameplayBootstrap, InventoryCatalog, and QuestView communicate intent. Names such as NewManager2 advertise archaeology. Good asset organization shortens investigation time, improves merge safety, and helps new contributors predict where a change belongs before they open the first file.
When Should You Use Assembly Definitions and Interfaces?
Assembly definition files are most valuable as dependency rules. Faster compilation can be a welcome result, but I do not create an assembly merely to reduce compile time. I create one when a module should have an enforceable public boundary. A gameplay assembly may reference shared contracts, while a UI assembly references gameplay contracts. Core gameplay should not reference UI simply because one screen needs a new label.
Start with a small graph. A project might have assemblies for core contracts, gameplay, presentation, platform adapters, editor tools, and tests. Feature-specific assemblies can be introduced when features are sufficiently independent or reusable. Creating an assembly for every folder produces administrative work without necessarily improving design. Circular references are usually a sign that responsibilities need to move, not that the compiler is being difficult.
Interfaces belong at volatile seams. I use them when multiple implementations are expected, when an external SDK needs isolation, or when testing requires a controllable substitute. I do not create an interface for every class. An interface with one implementation and no plausible replacement can add ceremony while hiding a simple relationship.
using System.Linq;
using NUnit.Framework;
public sealed class AssemblyBoundaryTests
{
[Test]
public void GameplayAssembly_DoesNotReferencePresentation()
{
var references = typeof(MatchController)
.Assembly
.GetReferencedAssemblies()
.Select(assemblyName => assemblyName.Name);
Assert.That(references, Does.Not.Contain("Project.Presentation"));
}
}
This small architecture test turns an intention into an executable rule. The exact assembly and type names will differ by project, but the principle is stable. If gameplay accidentally takes a dependency on presentation, the test fails during development instead of allowing the dependency to spread.
Boundaries also need escape hatches for debugging and iteration. Public contracts should expose what another module genuinely needs without revealing every internal object. If developers repeatedly bypass an interface, I inspect the contract before blaming the developers. A boundary that makes routine work unnecessarily painful will eventually be ignored. Good architecture guides the common path and makes the dangerous path visibly unusual.
How Can You Prevent Global State and Event Chaos?
Global state feels efficient because it eliminates wiring. A singleton exposes audio, configuration, inventory, scenes, analytics, and saves from anywhere. The first calls are convenient. The cost appears later when initialization order becomes implicit, tests affect one another, and every system can modify state without showing that dependency in its constructor, serialized fields, or setup method.
I do not ban singletons categorically. Some application-wide services genuinely have one active instance. I do insist that access and lifetime remain controlled. A composition root can construct services, connect features, and pass narrow interfaces to consumers. That gives the project one place where high-level dependencies are visible. It also allows a test, editor tool, or alternate platform implementation to provide a substitute.
Events need the same discipline. An event is useful when the publisher should not know which systems respond. It is dangerous when it becomes a hidden command bus with unclear ordering. I name events as completed facts, such as LevelLoaded or PurchaseConfirmed, rather than vague instructions. I unsubscribe symmetrically, document whether delivery is immediate, and avoid relying on event order unless a coordinator explicitly owns that sequence.
Static events are especially easy to leak across play sessions when domain reload settings change. Unity's configurable Enter Play Mode behavior can expose assumptions about static initialization and cleanup. Every static cache or event should have a deliberate reset strategy. Otherwise, a project may behave differently after the second Play press than it does after an Editor restart or in a player build.
The best diagnostic question is simple: can I understand this component's inputs by looking at it? Serialized references, constructor parameters in plain C# classes, and explicit initialization methods make dependencies visible. Calls to global locators and unnamed event channels hide them. Visibility does not automatically produce good design, but hidden dependencies almost always make maintenance harder.
What Automation Keeps Unity Architecture Honest?
Documentation explains intent, but automation protects it. A folder convention in a wiki will drift if nothing checks it. An assembly rule described during onboarding will be forgotten under deadline pressure. I automate the decisions that would be expensive to rediscover during a release, package update, or platform migration.
Architecture tests can verify assembly references, forbidden namespaces, or unwanted dependencies on vendor SDKs. Editor validation can locate missing prefab references, duplicate identifiers, mutable configuration assets, and scenes that contain prohibited components. Build scripts can confirm that required scenes, scripting defines, and platform settings are present. These checks are not glamorous, but they transform tribal knowledge into repeatable feedback.
I keep validators focused and actionable. A warning that says an asset is invalid is much less useful than one that identifies the asset, explains the violated rule, and suggests a repair. Slow validation also gets skipped, so I divide checks into fast feedback for normal development and comprehensive checks for continuous integration or release preparation.
Tests should target boundaries rather than attempt to simulate the whole game through one enormous scenario. Pure rules can use Edit Mode tests. Component behavior can use focused Play Mode tests. Platform adapters can be tested against fake implementations of their contracts. A small number of representative scene-loading and build tests can then verify that composition still works.
Version control is part of this system. Text serialization, visible meta files, stable asset naming, and small pull requests make Unity changes reviewable. Large scene and prefab edits deserve the same scrutiny as C# changes because they can alter dependencies just as easily. If a project has already become difficult to reason about, a focused Unity architecture and performance review can identify the highest-risk seams before a team commits to a broad rewrite.
The goal is not to reject every imperfect change. It is to provide fast, objective feedback while the context is still fresh. Architecture survives when the cheapest path for a developer is also the intended path.
How Should You Refactor Without Stopping Feature Work?
I am skeptical of complete rewrites. They remove visible technical debt while introducing untested assumptions, delayed features, and a long period in which the old and new systems both demand attention. A safer approach is to improve the seam required by the next real change. Production work then pays for architectural progress instead of competing with it.
Suppose a feature talks directly to a platform SDK. Before adding another platform, define the smallest contract the feature needs and place the existing integration behind an adapter. Keep behavior unchanged. Add tests around the contract. Then implement the second adapter. This sequence creates immediate value, proves the abstraction against real implementations, and avoids designing a universal platform layer in isolation.
I use the same approach with large managers. First identify one responsibility, such as session state, content loading, or reward calculation. Add a clear boundary around it, move behavior with tests, and leave a compatibility path for existing callers. New code uses the extracted service. Old code migrates when touched. The original manager gradually becomes smaller instead of being replaced in one risky merge.
Folder moves should be separated from behavior changes whenever possible. Unity meta files make asset identity stable when moved correctly, but huge reorganizations still create merge conflicts and noisy reviews. A clean hierarchy is not worth losing the ability to inspect what actually changed. I prefer a sequence of understandable commits over one perfect-looking final tree.
Refactoring also needs a stopping rule. The target is not theoretical purity. It is reducing the cost and risk of expected changes. Once a seam is clear, covered, and easy to use, I move back to product work. Touch Camera PRO has remained valuable to me as a lesson in this balance. A reusable Unity product must evolve, but unnecessary internal churn can be just as disruptive as neglected code.
When teams reserve refactoring for a future cleanup phase, that phase rarely arrives in the form they imagine. Sustainable projects make cleanup part of ordinary delivery, one dependency and one validated boundary at a time.
What Does a Maintainable Unity Project Look Like in 2026?
By 2026, a Unity project may need to absorb engine updates, package changes, new input devices, storefront requirements, rendering decisions, and platform SDK revisions during the same product lifetime. Maintainability does not mean predicting every change. It means arranging the project so that the unpredictable parts are contained at replaceable edges.
A maintainable project is usually boring to navigate. Features have obvious homes. Runtime and editor code are separate. Third-party packages do not leak types through every gameplay class. Scenes have documented ownership and lifetime. Core rules can run without loading a presentation scene. Build configuration is repeatable. A developer can remove or replace a platform adapter without opening unrelated systems.
It should also be easy to delete code. Deletion is an underrated architecture test. If removing an abandoned feature requires editing dozens of unrelated prefabs and managers, the feature never had a boundary. If its assembly, assets, registrations, and tests can be removed as a coherent unit, the structure is doing useful work.
I would not apply all of these practices on the first day of a weekend prototype. I would apply them as uncertainty decreases and commitment increases. The moment a project gains multiple platforms, external SDKs, several contributors, reusable content pipelines, or a planned life beyond its first release, explicit boundaries begin paying for themselves. My work across shipped VR, freelance Unity development, and Asset Store products has made that transition very visible.
The practical test is whether the team can answer a future request with a bounded plan. Which module changes? Which contract protects the rest? Which automated checks prove that the change is safe? If every answer is the entire project, architecture needs attention. If the answer is a small set of owned systems with clear integration points, the project is positioned to survive change.
Unity will keep evolving, and production requirements will evolve with it. The durable advantage is not a particular folder template or framework. It is a codebase that makes dependencies visible, isolates volatile technology, and allows developers to improve one part without destabilizing everything else.
Top comments (0)