What is the correct way to handle data access to a user-selectable file using dependency injection in a WPF MVVM application?

72 Views Asked by At

Consider the following situation:
A MVVM desktop GUI application where the user can create/edit a "project".
Think of a project like the Visual Studio solution: It's a file (currently an XML file) that the user can create, open, save and close.
An instance of the application can have only one project open at a time but the user can have multiple projects stored on it's disk. Once a project is loaded, the user can perform CRUD operations on a list of items (For simplicity, this example will have a single list of "Users").
If the user wants to edit another project, it can close the currently open one or launch another instance of the application.

I'm having some difficulty to understand what is the correct way to implement this using inversion of control and dependency injection in a MVVM WPF app.

My current idea is the following:

Data layer

class User
{
    public int Id { get; set; }
    public string Name {get;set;}
}

// This exposes a "project" content without details of the file type.
// Internal: Not visibile to assembly with GUI (ViewModels)
// Registered in the IoC container as Singleton, so that repositories will always get the same "currently loaded" project.
internal interface IProjectDb
{
    List<User> Users { get; }
    /** other lists if required.. **/
    void Init(string path);
    void SaveChanges();
}

class XmlProjectDb : IProjectDb
{
    /**
    XML implementation:
    Init will deserialize from file.
    SaveChanges will serialize to file.
    **/
}

// Allows operations to User list to ViewModels
// Registered as transient in the IoC container.
public interface IUserRepository
{
    IEnumerable<User> GetAll();
    User GetById(int id);
    User GetByName(string name);
    void Update(User user);
    /** etc.**/
}

class UserRepository : IUserRepository
{
    // This is injected from the IoC container.
    // Is a singleton since once loaded I need to work on the same "project" file.
    private readonly IProjectDb _db;

    /** implementation that uses the injected "_db" instance **/
}

// Allows GUI ViewModels to load and save project
// Registered in the IoC container as Singleton, so that VMs will always get the same "currently loaded" project.
public interface IProject
{
    void Init(string path);
    void SaveChanges();
}

class Project : IProject
{
    // This is injected from the IoC container.
    // Is a singleton since once loaded I need to work on the same "project" file.
    private readonly IProjectDb _db;

    public void Init(string path) => _db.Init(path);
    public void SaveChanges() => _db.SaveChanges();
}

UI ViewModels

// "Root" ViewModel that exposes commands to Create/Load/Save/Close a Project.
// Once a Project is loaded, it exposes a UserListViewModel that the View will bind as DataContext to a UserListView
class MainViewModel
{
    private readonly IProject _project;
    private readonly IUserListViewModelFactory _userListViewModelFactory;

    public UserListViewModel UserListViewModel {get;set;}

    // Called when user selects "Create new project" from menu
    public void CreateNew()
    {
        string path = /* get path from dialog */
        _project.Init(path);
        UserListViewModel = _userListViewModelFactory.Create();
    }

    // Called when user selects "Create new project" from menu
    public void Load()
    {
        string path = /* get path from dialog */
        _project.Init(path);
        UserListViewModel = _userListViewModelFactory.Create();
    }

    // Called when user selects "Save" from menu
    public void Save()
    {
        _project.SaveChanges();
    }

    public void Close()
    {
        _project.SaveChanges();
        UserListViewModel = null;
    }
}

class UserListViewModel
{
    private readonly IUserRepository _repo;
    private readonly IUserViewModelFactory _userVmFactory;

    public ObservableCollection<UserViewModel> Users {get;}

    public void Load()
    {
        foreach(User user in _repo.GetAll())
        {
            UserViewModel vm = _userVmFactory();
            vm.Load(user.Id);
            Users.Add(vm);
        }
    }

    public void AddNewUser()
    {
        UserViewModel vm = _userVmFactory();
        vm.Load(0);
        Users.Add(vm);
    }
}

class UserViewModel
{
    private readonly IUserRepository _repo;

    /* omitted properties and other MVVM stuff */

    // Called by who created the ViewModel to load it with data for a User
    public void Load(int id)
    {
        User user = _repo.GetById(id);
        // .. load vm data from model instance
    }

    // Called by the "save" command
    public void Save()
    {
        User user = new User();
        // .. Save vm data into model instance.

        _repo.Update(user);
    }
}

As you can see I'm taking advantage of the singleton IoC container registration to handle the fact that I need to share the same instance of the "currently opened project" to different ViewModels and services.
Is this the correct way to handle a situation like this?

Also in the example, there is only one implementation of the "IProjectDb" interface that operates on an XML file.
How could this architecture handle the case where I need to choose the correct implementation depending on the project file extension?
I could use a factory that creates the instance by looking at the extension but that will mean that each repository will need to store the file path string in order to get the correct IProjectDb instance from the factory.

Thank you for your support

1

There are 1 best solutions below

3
BionicCode On

While a question like "is this implementation conform with a particular design pattern" is not opinion based a question like "what is the correct way" is.

Some thoughts:

  • Handling the dialog to obtain the file path must happen in the view (usually code-behind, either in the dialog itself or the dialog's owner). A control must not be managed by any class of the view model. A dialog is a UI control specifically designed to interact with the user. The view model component neither knows the view nor the user, so it naturally can't have any interests in dialogs.

    You can pass the dialog result to the view model using the usual suspects: data binding, as argument of a method call, as command parameter. All options are 100% legit MVVM.

  • You should not scatter the data/data model management all over your code. Declare a top-level class (of the participating view model classes) that manages the complete edit process. Such a class would take hold of the edited/created entities and would pass it around to other view model classes (if really necessary). This class would create a new and initialized User and pass it to the view model for a particuöar context.

    This way you have all the access to the repository (model) related to that ongoing process in one place. This will also remove dependencies from your view model classes (the IUserRepository for example). Aiming to reduce class dependencies (and therefore limiting information and responsibilities) to a minimum will always result in cleaner code, in every aspect.

  • Take a look at the IEditableObject interface. It's useful as it introduces a nice and clean object editing pattern to your application, that even enables undo/cancel and redo. Signal the managing view model class (or the owner class of this CRUD operatiorn in geneal) that the editing has completed via an event to allow it to push the updated data to the repository.

  • Hide as much information about your application model from the application view model as possible. There should be no implementation details leak from the MVVM model. For example, provide a simple API that abstracts away the different types of repositories or data sinks. Those are details that the view model must not be concerned about. It just asks for data and passes data back. In this sense:

    "How could this architecture handle the case where I need to choose the correct implementation depending on the project file extension?"

    Usually, you would have a kind of facade to hide this details. Then let the view model pass the data and let the facade decide what repository to use to persist (or read) the data. Whether it is an XML file or a JSON file or a database. The view model must not know about this possibilities. It's not its responsibility.

    Consider to expose a single (per context) and very general repository API to the view model, for eaxmple GetUser or SaveUser. Internally, this repository would use more specialized repositories with a more specialized API, for eaxmple GetUserFromDatabase or SaveUserToJson.