M104 - Architecture & Loose Coupling
In the physical world, a coupler is something that allows for energy, motion, or signals to be transferred between one or more components. In the digital world, a coupler is used to transmit data between different components. The couplers on a train car are built to a standard (interface) that allows any train car to be easily joined to another while being flexible (loose) in the X, Y, & Z axis simultaneously and are designed to be easily repaired or replaced by the train crew should one break.
In a previous article, I briefly discussed how to compose a MAUI application out of views, layouts, and pages and also why you might need to create your own Shell to fit the requirements of your application. At some point, you will need connect different components together to allow them to communicate with each other. In this article I am going to discuss what Loose Coupling is, its benefits, and how the following principles and patterns are used in a loosely coupled MAUI application.
What is Loose Coupling?
When items or components are connected together, they can typically have an adjective (loose to tight) associated with the coupling describing the amount effort required to uncouple the items and reuse or replace them. In a software system, its typically desirable to have items loosely coupled so that they can be swapped out to test or to make the software more adaptable to change.
To help illustrate this point, take a look at the following image that shows the power connection to a laptop and think about the benefits of the loose couplings based on standard interface contracts:
Benefits of Loose Coupling
When you implement the loose coupling techniques I am going to discuss in this article within your MAUI applications, the following benefits can be realized:
What is Loose Coupling in Software?
Now that you understand the benefits of loose coupling in general, let's take a look at how it can provide the same benefits to software as it does in the physical world. In a tightly coupled system, modules or components are highly dependent on each other, typically by component A creating or instantiating a reference to component B making maintenance and scaling difficult. In a loosely coupled system:
Within .NET MAUI, loose coupling is achieved by understanding and using the following design patterns to promote modularity, maintainability, scalability, and testability.
Model-View-ViewModel (M-V-VM)
The MVVM pattern decouples the user interface (View) from the business logic (ViewModel) and from the data objects (Model). By defining the contracts (properties, commands, methods, etc.) on the view model and model, databinding can be used as a loose coupling between them allowing the components to be developed separately and not require code to be written to manually connect the components.
The above image shows you the basic MVVM pattern and how its applied in .NET MAUI towards building a loosely coupled application. Starting in the top left and working clockwise:
Databinding
Databinding within a XAML application has been around for quite a while and it allows for properties on UI to bind to properties on other controls, the control itself, the ViewModel, or a Model. When properties are configured to use databinding, they will fetch and display the value upon initialization and will listen for changes to the bound property. If the bound object implements the INotifityPropertyChanged interface (contract) and then raises the PropertyChanged event, the listener can update the user interface with the new value.
The CommunityToolkit.Mvvm NuGet package contains various helpers, base objects, and attributes that can reduce the amount of code you need to write for databinding and handling properties changing:
This will allow the views to bind to properties in the ViewModels and the toolkit will generate a lot of optimized boilerplate code for you to aid in the databinding and loose coupling within your MAUI application.
Inversion of Control and Dependency Injection
A key component of loosely couple systems is the Inversion of Control (IoC) pattern where dependencies are supplied to a component instead of creating the dependencies from inside the component. IoC is a broad concept that can be implemented in different ways, but MAUI has built in support for IoC through dependency injection most commonly through the constructor. This is accomplished by registering your services with the service collection on the application builder when the application is launched.
NOTE: Regardless of how its registered with the dependency injection container, the object won't be created until it has been requested the first time, which could be an issue if you have background services that will be listening for messages or need to be executed on a periodic basis. In order to ensure that these services have been initialized, you can test resolving those services from the App constructor which will ensure they are instantiated and running.
It is also a good idea to store the service provider when your app starts so you can easily resolve objects and their dependencies elsewhere in the application.
Injecting Dependencies into UI Components
If your Page, its ViewModel (and dependencies) are registered in the services collection, MAUI makes it simple to resolve the pages dynamically from the dependency injection container. The shell will handle this for you automatically when navigating but even if you aren't using the shell, you could still resolve your own pages from the dependency injection container and perform your own navigation.
In the MVVM portion of this article, I showed a tight coupling because the View directly instantiated the ViewModel only until I could explain other concepts in this article. Views are slightly more complicated because they are children of a page or other views and may be several levels deep in the visual tree and a new instance of the view or control is created each time it is placed in the XAML. As a result, either a constructor with no parameters (default) is required or simple parameters must be supplied at design time through arguments (see the official documentation here for passing arguments into view constructors). When you compose your MAUI application and add ContentViews to your project, you should think of them as Custom Controls. When you place a control in the XAML, you set properties on the control and sometimes data bind these properties to supply data at run time.
Every control has a BindingContext property that defaults to parent's binding context through inheritance ultimately up to the Page.BindingContext unless a control in the hierarchy overrides this behavior. The most flexible way to get data into a view / control and optionally to the underlying ViewModel is through property chaining and two-way data bindings which vary slightly if the entity is already in memory and accessible or not.
The above image shows the view model for the page with service supplied by dependency injection and the Model entity property (Person) is set by the service call to GetPerson(). Below you will see how data binding is passing that value from the ViewModel of the page to a property on the view called "Entity".
Property Chaining with Data Entity
One scenario is where you only need to get the data entity into the control because there is no need for complex logic to be placed into a view model for the view including executing commands. The controls on the view will be able to use a binding that specifies the source as the control itself. This is accomplished by:
If there is any other logic other than exposing a bindable property, you should create a corresponding view model for the view so that you can encapsulate the logic, resolve any dependencies, and make it testable. After you respond to the entity being set (by adding the propertyChanged: on[Property]Changed to the bindable property and add the corresponding method, you can resolve and set the ViewModel property of the View. The only difference is an additional layer in the binding where the ViewModel property and then the entity property name on the ViewModel is added before the field name.
Property Chaining with ID or Key
Now that you understand the basics to binding and dependency injection on pages and resolving ViewModels and their dependencies by property chaining for entities already in memory, the next logical step is to handle the same scenario when the entity isn't already in memory. In this case you would be passing an ID or a key as the BindableProperty instead of the actual entity and then the view model for the view would fetch the entity. In order to fetch the entity, you might need an additional dependency set in your view but then you have multiple properties and could encounter errors depending on which property is set first. Furthermore, if you are editing your data, you also might need to communicate from your page or parent view to save or discard changes in a child view.
What if there was a better way where you could simply request the actual entity for the key value and could also respond to external calls to save, discard, refresh, etc.? The better approach is to utilize messaging within your application to request the entity from a key or ID value and a service running in the background is listening for that request and responds in kind with the value you were requesting.
Messaging & Event Aggregation
As I just mentioned in the previous section, the support for messaging and event aggregation in .NET MAUI is available in the CommunityToolkit.Mvvm NuGet package. This is one of the most powerful tools at your disposal for creating loosely coupled applications in .NET MAUI as it allows you to send and receive arbitrary messages within your application (and packages, plugins, etc., if your application can share a common library for the message definitions). As you will see, sending messages is simple but it first requires creating a strongly typed message class that inherits from one of the provided base classes depending on whether its sending a broadcast message or sending a message to request information.
ValueChangedMessage<T>
AsyncRequestMessage<T>, AsyncCollectionRequestMessage<T>, RequestMessage<T>, CollectionRequestMessage<T>
Once you have a message defined, you can send the message by accessing the default instance one of the messengers provided in the package, calling the send method and passing in an instance of your strongly typed message.
In an asynchronous request, the sender needs to await the Send, and the subscriber needs to reply with an asynchronous method that returns the desired object of type T.
When you are done listening for messages, you should either call Unregister or UnregisterAll in order to stop listening for messages, otherwise you might have a component that has not been garbage collected receiving messages that weren't intended. You can also register a subscription on demand and then unregister that when the message was received or a timeout.
It is important to not change the values of any of the parameters in the value of the ValueChangedMessage or the RequestMessage itself because several threads could be accessing the received data simultaneously. Payload data should be immutable but how the response data is accessed will be up to you and your design as to whether it is safe to modify the response.
Interfaces and Abstraction
Another key feature of loosely coupled systems is the use of interfaces especially in conjunction with dependency injection. An interface is an abstraction or contract of the properties, events, commands, methods, etc., for which a class must implement. Coding against an interface allows for one implementation to be swapped out with another depending on the need and also allows for parallel development as the actual implementations don’t need to be completed before the views can be built.
To better understand this, let's take a look at a tightly coupled component and then the alternative.
The view model is creating a tight coupling to one specific concrete implementation of the service. Even if the type was passed in via the constructor, it would still be tightly coupled as it can't be swapped out for something else. For the purposes of this example, let's assume that the GetPerson() method fetches data from an API over the network and stores it in a local database before returning the value. I wouldn't be able to test or run the application if I didn't have an internet connection. Furthermore, using a local database complicates being able to unit test the view model as the unit tests now have to create and populate and destroy a local database for testing purposes and ensure that the other unit tests are written in such a manner that they don’t access the same test data that could change.
In order for the view model and service to be loosely coupled, we would need to define and then implement an interface for the service. This would allow for the concrete service implementation to be swapped out for any number of reasons, such as for testing purposes, a change in API provider and protocols, a change in local database storage, etc.
In the following image you can see how I am using the Mock library to setup a mock service that returns something different and does not need to access the internet or a local database.
By using an interface contract, I now have a loose coupling between the ViewModel and service which allows it to be swapped out if necessary, including creating a mocked-up service for testing or development when services or infrastructure aren't available or if the implementation code for the service isn't completed yet. If the service itself had dependencies and used interfaces for those dependencies, they could be mocked up as well and all and setup in the dependency injection container. This allows for changes to be made at one place in the application when the dependencies are registered and doesn't require code to be changed everywhere the service was used. Interfaces and abstractions are used by MAUI in order to be able to swap out the implementation of code that varies depending on the platform.
Summary
In conclusion, embracing loose coupling in .NET MAUI applications offers numerous benefits that enhance the overall quality and maintainability of your software. As you continue to develop with .NET MAUI, incorporating these principles to build loosely coupled components will help you build robust and resilient applications that can evolve with changing requirements and technologies.