Skip to content

Conversation

@popuz
Copy link
Collaborator

@popuz popuz commented Dec 24, 2025

Pull Request Description

Simplified and improved the MVC State Machine architecture by removing unnecessary abstractions, applying SOLID principles, and introducing a hybrid approach that combines the benefits of generic typing with interface flexibility.

What does this PR change?

1. Removed TContext from State Machine.
Context from the StateMachine is replaced by direct injection via constructor, because Context abstraction had several issues:

  • States were using it only partially and some states were not using it at all — creates unneeded coupling
  • It can easily become a "God Object"
  • It was violating the "Law of Demeter"
  • Dependencies of the state were obfuscated

Replaced solution improves encapsulation and readability — it is clear what is the dependency of each state.
Deleted files:
ChatStateContext.cs
ChatInputStateContext.cs

2. Removed State Machine injection into base state
Similarly, injection of stateMachine and its disposeCancellationToken into the state were replaced by direct injection in the state constructor, since not every state needed it. Indeed, only one state was depending on the disposeToken, and only states that perform transitions need the machine reference.

3. Removed unused Update/LateUpdate methods (YAGNI)
Unused methods such as Update(float deltaTime) and LateUpdate(float deltaTime) were removed, since no state was using them. Moreover, I don't see it coming for such UI-based state machine. More probably, we will extend state machine with transitions rather than with Update-loop behavior.

4. Replaced CRTP with Interface-based approach
CRTP (Curiously Recurring Template Pattern) for MVCState was replaced by a simplified approach via interfaces:

Before (CRTP):

public abstract class MVCState<TBaseState> where TBaseState : MVCState<TBaseState>
public class ChatState : MVCState<ChatState>

After (Interfaces):

// IExitableState ( Exit only)
//     ├── IState ( Enter without parameters)
//     └── IPayloadedState<T> (Enter with parameters)

public interface IExitableState { void Exit(); }
public interface IState : IExitableState { void Enter(); }
public interface IPayloadedState<in TPayload> : IExitableState { void Enter(TPayload payload); }

5. Hybrid Generic + Interface approach for State Machine
Combined the best of both worlds — typed CurrentState with interface flexibility:

public class MVCStateMachine<TBaseState> : IDisposable 
    where TBaseState : class, IExitableState
{
    public TBaseState? CurrentState { get; }  // Typed!
    
    public void Enter<TState>() where TState : TBaseState, IState;
    public void Enter<TState, TPayload>(TPayload payload) where TState : TBaseState, IPayloadedState<TPayload>;
}

Benefits:
✅ Compile-time type safety for CurrentState
✅ Support for payloaded states via IPayloadedState<T>
✅ Simpler to understand than CRTP
✅ Flexible — states can implement multiple interfaces

6. Simplified IndependentMVCState
Removed unnecessary generic layers and context from IndependentMVCState. Now it's a single simple class with Activate()/Deactivate() pattern.

Migration
States now receive their dependencies explicitly via constructor:

// Before: dependencies hidden in context
public class MyState : MVCState<MyState, MyContext> { }

// After: explicit dependencies
public class MyState : IState
{
    public MyState(MVCStateMachine<BaseState> machine, SomeDependency1 dep1, SomeDependency2 dep2) { }
}

Files Changed

  • MVCStateMachine.cs — rewritten with generic + interface approach
  • MVCState.cs — deleted, replaced by IState.cs
  • IState.cs — new, contains IExitableState, IState, IPayloadedState<T>
  • IndependentMVCState.cs — simplified
  • All state implementations updated to use new interfaces
  • All context files deleted

Test Instructions

This refactor touches mainly chat, but without adding or changing its functionality.
In theory, chat should work as in current dev, so please make a regression test on the chat features.
If possible, please verify that found issue is not presented in the dev

Quality Checklist

  • Changes have been tested locally
  • Documentation has been updated (if required)
  • Performance impact has been considered
  • For SDK features: Test scene is included

Code Review Reference

Please review our Code Review Standards before submitting.

@popuz popuz self-assigned this Dec 24, 2025
@github-actions
Copy link
Contributor

github-actions bot commented Dec 24, 2025

@popuz popuz marked this pull request as ready for review December 24, 2025 18:00
@popuz popuz requested review from a team as code owners December 24, 2025 18:00
@popuz popuz requested a review from biotech77 December 24, 2025 18:37
@popuz popuz changed the title Chore/mvc state machine refactor Chore: mvc state machine refactor Dec 29, 2025
@popuz popuz marked this pull request as draft December 29, 2025 10:21
/// </summary>
public class BlockedChatInputState : ChatInputState
{
private readonly MVCStateMachine<ChatInputState> stateMachine;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a questionable improvement as it moves the generic constraint from the base class to the implementations themselves.

Copy link
Collaborator Author

@popuz popuz Dec 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

previously State could have only StateMachine that works on the same State type via its method injection SetMachineAndContext. But even with this constraint States still could have another StateMachine injected directly via constructor (for example if I have some higher level UI/Canvas with its (meta-)states and inside one of such state I want to change states of some child UI of that canvas - I would inject another StateMachine via constructor quite similar to what is currently done in the PR). Thus, it wasn't really a constraint, but a forced dependency.

With removal of SetMachineAndContext (forced injection of the state-machine) it become clear which state really can change to another state or operate on another subset of states (via explicit type in the constructor parameter).

Currently, I found 3 states that don't need forced dependency on the StateMachine:
HiddenChatInputState, InitChatState, HiddenChatState

Copy link
Collaborator Author

@popuz popuz Dec 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

my problem with such forced dependency which is hidden somewhere inside a base class is that when I start to read the code and try to figure out who is making state transitions and who is not - I need to use "Find Usages" of the base MVCStateMachine.ChangeState that gives me all usages including not related to my current BaseState, and go from there.
Not a biggest problem of course, and I can move this injection back (but making it base class constructor injection instead of method injection)

void Enter();
}

public interface IPayloadedState<in TPayload> : IExitableState
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you justify the usage of IPayloadedState and, thus, the split between IExitableState and IState? In the chat states as far as I recall, there are "parameterized" transitions which are made via a shared state instead of the input parameters

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I want to implement state machine for the Authentication flow and there I would like to Enter some states with parameters. For this refactor I can remove this implementation and merge Exit/Enter into one IState interface.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

merged it into one interface here 614c59d

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

examples of payloaded states are in this PR
Explorer/Assets/DCL/AuthenticationScreenFlow/AuthenticationFlowStateMachine/ProfileFetchingAuthState.csExplorer/Assets/DCL/AuthenticationScreenFlow/AuthenticationFlowStateMachine/ProfileFetchingAuthState.cs

It is exactly example of the state that implements only IPayloadedState and calling Enter on it without payload would be incorrect. However, there are also states that can implement both IState and IPayloadedState and support both entrance - like this one
https://github.com/decentraland/unity-explorer/pull/6559/files#diff-0d6efdf4b177b277394e07ec898cf444b03f7f3ab52152a85f54040b90f38812R30-R49

@popuz popuz marked this pull request as ready for review December 29, 2025 12:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants