M104 - Architecture & Loose Coupling

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.

  • Model-View-View Model
  • Data Binding
  • Inversion of Control and Dependency Injection
  • Messaging and Event Aggregation
  • Interfaces and Abstractions

 

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:

  • The power receptacle and the plug on the adapter allows the adapter to be used in any power outlet
  • The separate plug portion of the power adapter allows for one part to be swapped out to handle different countries and to reuse the power adapter itself
  • The standard power and data cable between the adapter and the laptop can be swapped should it break or the requirements change (need a longer cable, need a cable with different connectors on the ends, etc.)
  • The cable can also be used with the adapter to power other devices or it could be used to connect another device to the laptop such as a phone, tablet, monitor, etc.

Article content

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:

  • Improved Maintainability - Easier to modify as changes can be isolated, developed, and tested independently
  • Better Testability - Ability to use mocked dependencies on other components to narrow testing scope
  • Enhance Scalability - Coding against contracts and interfaces allows for parallel development and testing
  • Greater Flexibility and Extensibility - Interface implementations can be swapped out to change functionality and adapt to changing requirements
  • Reduced Code Duplication - Allows for more reuse of components, models, and logic across projects
  • Improved Performance and Efficiency - Lazy loading and dependency injection can avoid unnecessary object creation improving memory management

 

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:

  • Components interact through well-defined interfaces
  • Changes in one module have minimal impact on others
  • Components can be easily replaced, modified, or tested independently of each other

 

Within .NET MAUI, loose coupling is achieved by understanding and using the following design patterns to promote modularity, maintainability, scalability, and testability.

  1. MVVM (Model-View-ViewModel) Pattern
  2. Inversion of Control and Dependency Injection
  3. Messaging & Event Aggregation
  4. Interfaces & Abstraction

 

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.

Article content
A sample MVVM pattern

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:

  1. Page - MAUI applications need at least one page to present information to the user. 
  2. UI At Runtime - The page and View at runtime with the Button Text specified (bound to the ViewModel
  3. View - Defines the visual appearance of the page and specifies the bindings from View to ViewModel.
  4. UI After Button Pressed - Databinding updates the name fields initially and after the swap (second press)
  5. ViewModel - Inherits ObservableObject and exposes properties and commands for databinding to the view
  6. Model - Inherits ObservableObject and is the data model which could have come from an API or database call 

 

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:

  • Inherit from ObservableObject
  • Mark class and properties as partial
  • Apply the [ObservableProperty] attribute to the properties
  • Implement the On[PropertyName]Changed method  

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. 

Article content
Adding Singletons or Transients to services collection

  • AddSingleton - Only one instance of the object will ever be created so this would be used for background services, cross cutting concerns, or components that may take significant amount of CPU time to start and initialize
  • AddTransient - A new instance will be created each time it is requested.  Pages and view models would typically be registered this way ensuring that you have a clean starting point each time a page is being displayed.  Once the view model is instantiated, you will likely need to pass in initialization data by navigation parameters, manual initialization, or sending a message 

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.

Article content
Resolving a dependency from the service provider

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.

Article content
Storing and making the service provider available to the rest of your 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.

Article content
Page view model with service injection

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".

 

Article content
Mapping the property from the page view model into the view

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:

  • Adding the entity type as a BindableProperty in the code behind
  • Set the x:Name property on the view to a unique name
  • Use the x:Reference for the source in the bindings
  • Add the Property name of the entity before the individual property (here the property is called Entity)

Article content
Using an entity in memory mapped into a view directly

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.

 

Article content
Code behind for view showing entity changed and populating view model

 

Article content
Modifications to view to bind to the entity on the view model

 

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>

  • These are one way notification messages from publisher to subscriber (but subscriber could also publish) 
  • The generic type <T> is the type that has changed, and a value of that type is supplied to the message and sent
  • If you need to send more than one piece of information, use a strongly typed wrapper class for the <T>
  • One or more publishers may send any particular message
  • One or more recipients may subscribe to this message, but it is not required
  • Therefore, no response is received from these messages
  • No direct indication that it was processed
  • Messages are synchronous meaning that all subscribers will be processed before call returns
  • Can be used to kick off background or asynchronous processes by adding to a queue or running a new Task
  • Subscribers could notify that background processing was complete by sending their own ValueChangedMessage

Article content
Publishing and subscribing to broadcast messages

AsyncRequestMessage<T>, AsyncCollectionRequestMessage<T>, RequestMessage<T>, CollectionRequestMessage<T>

  • These are request messages and are a round trip from publisher to subscriber(s) and back to the publisher
  • The generic type <T> is the type that will be returned from the subscriber
  • If you need to send parameters along with this message, add them to the derived message
  • The request message has an implicit converter that will convert to type <T> so at least one recipient must reply to the message when setting the response otherwise an exception will be thrown when setting the response directly to the type T
  • If you need to request a message but it may not get a response, then store the returned message in a variable and then manually check if a result is returned on the message which will bypass the automatic exception if a response is not received from the send method
  • Messages can be synchronous or asynchronous depending on which base class you use
  • Subscribers of the message call the Reply() method on the message itself passing in the value of type <T>
  • Subscribers of AsyncRequestMessage<T> must "reply" with an asynchronous method that returns the type <T> otherwise the call returns, and execution continues in the sender.
  • The collection request messages can be used to request a message which can receive multiple replies.  These requests can be useful when you need to request information that may be provided from multiple sources like repository counts, services requiring initialization, instances of a particular type (open customers, orders, etc.), searching across different entities.

 

Article content
Publishing and subscribing to request messages

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. 

  • WeakReferenceMessenger - Uses weak references to enable easier cleanup for subscribers and is a good option when you don’t have a clearly defined lifecycle
  • StrongReferenceMessenger  - Uses strong references which can result in better performance if your subscription lifecycle is known and controlled

 

Article content
Defining, sending, and subscribing to a broadcast message

 

Article content
Defining, sending, subscribing, and replying to a request message


Article content
Defining, sending, subscribing, and replying to an asynchronous request 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.


Article content
Unregistering message subscriptions

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.

Article content
Tightly coupled service to view model

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.


Article content

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.

To view or add a comment, sign in

Others also viewed

Explore topics