Introduction
In this article I will show how to use the Model View Presenter pattern to remove logic from the UI and move it to a controller class. Furthermore, I will show an easy way to enable threading within the application and follow the rule of "no business work should be done in the UI thread". By applying threading correctly you should never have a situation where the UI is blocked while processing some business work.
Logon Example
To show the MVP pattern and all its glory I decided to use the logon screen example. We are all familiar with logon: a screen shows up asking for the user name and password. When the user presses the "Logon" button, work is initiated to validate if the user is authenticated on the system. Normally, logon is quick, however in complex systems, logging-on can take significant amount of time, I am going to show how to handle long operations with MVP, using a tiny framework which is based on the thread pool. But, first thing first, lets define our screen.
Creating the View
Views are really our UI layer. Normally these would be web pages or win forms, in this example I will use winforms. Using MVP, the view should really expose no business logic what's so ever. In fact, if you need to add an if statement you should ask yourself if it belongs in the view or the controller, unless your if statement relates with UI work, it probably belongs in the controller. The way I see views in MVP, are classes that at the core expose only properties and events - nothing else. There could be exceptions to this rule, but the goal is to make the view very simple. The view knows how to get data, and how to set data, but it doesn't know in which sequence or why. It is like a dummy puppet that provides all the ropes but without knowing the actual act.
Suppose our logon screen will have a user name field, password field, a status field (indicating if there is an error) and a button for performing the actual log-on. We can define the interface for the view without too much trouble.
public interface ILogonView
{
event EventHandler LogonEvent;
void Notify(string notification);
string Password { get; }
string UserName { get; }
}
Notice the interface contains a property with setter and getter for each field, and an event for pressing the log-on button. I have also added the Notify method, so I can notify my view from the outside, this method is used to setup the status field indicating if the logon is successful or not. I will talk about this method later. Using the ILogonView interface we get the extra benefit of having a view implemented in different ways, it can be a web page, or it can be a winform. It can even be a regular class - just used for testing.
public partial class LogonForm : Form, ILogonView
{
public event EventHandler LogonEvent;
public LogonForm()
{
InitializeComponent();
}
///
/// Get the User Name from the username text box
/// Trim the user name, and always return lower case
///
public string UserName
{
get { return mTextBoxUserName.Text.Trim().ToLower(); }
}
///
/// Get the password from the password textbox
///
public string Password
{
get
{
return mTextBoxPassword.Text;
}
}
///
/// Update the screen with a message
///
/// Message to show on the status bar
public void Notify(string notification)
{
mToolStripStatusLabelStatus.Text = notification;
}
private void mButtonLogon_Click(object sender, EventArgs e)
{
// fire the event that the button was clicked.
if (LogonEvent != null)
LogonEvent(this, EventArgs.Empty);
}
}
The Controller
The job of the controller (or the presenter) is to handle the events coming from the view, and use the view getters and setters properties to define the behavior of the view. Think of the view as a data source, just like a data layer, you can query the view for information and you set information to the view. The controller is the only component that knows exactly how to manipulate the view, and how to call the setters and getters in the right sequence.
public class LogonController
{
private ILogonView mView;
public LogonController(ILogonView view)
{
// register the view
mView = view;
// listen to the view logon event
mView.LogonEvent += new EventHandler(mView_LogonEvent);
}
void mView_LogonEvent(object sender, EventArgs e)
{
string userName = mView.UserName;
string password = mView.Password;
if ((userName == "mike") && (password == "aop"))
{
mView.Notify("User Logged On");
}
else
{
mView.Notify("Invlid user name or password");
}
}
}
The controller is listening to the logon event that the view fires. When a logon event handler is triggered, the controller queries the user name and user password and validats if the user name and password are correct. If there is an error message, the controller sets the status message on the view with a message. However there are a few problems... First, I don't know if you noticed, when I first showed the code of the view, it does not contain a reference to the controller. Therefore I need to modify my Form to include knowledge of the controller
public partial class LogonForm : Form, ILogonView
{
public event EventHandler LogonEvent;
private LogonController mController;
public LogonForm()
{
InitializeComponent();
mController = new LogonController(this);
}
// rest of the class unchanged
So far we are classic MVP, if you understand everything to this point, you have just understood MVP. Anything above this point is just little things that bug me.
- The controller is doing all the work, normally the logon work should not be done directly by the controller but delegated to a business layer class to handle the logon, ideally, a logon service.
- What if the logon takes 10 minutes to process, we can not keep the view frozen.
- What if we would like to send status back to the view during a business operation outside of the controller. how do we handle that?
- To be honest with you, I still feel the view should be beyond stupid and not know anything. But, as you can see the view knows about its controller, and how to create it.
Using a service Layer
OK. one problem at a time. The first task would be to remove the logon processing from the controller and move it to a service layer. Let's create a LogonService class, which accepts a user name and a password, and validates if the user is valid. Now we will use our service layer from the controller to perform the logon operation. Same idea, the controller handles the logon event handler and delegates the work to the service layer. The service layer actually does the work of logging and updates the status message on the screen with the outcome of the operation.
public class LogonService
{
public bool Logon(string userName, string password)
{
bool rc;
if ((userName == "mike") && (password == "aop"))
{
rc = true;
}
else
{
rc = false;
}
}
}
But, here again, we have some problems. What if our service is a long running service, and maybe executes many steps to log a user. This example is simple, but let's face it is never that simple in production code. We normally have to go to the database to validate a user, I would like to provide the service some way to report status back to the view. After all, our controller can do this, so should the service... An idea would be to introduce an interface to allow the service report status something like INotify
public interface INotify
{
void Notify(string notification);
}
Now this should look familiar, our View has this method... (take a look)
So, lets break this into 2 interfaces...
public interface ILogonView
{
event EventHandler LogonEvent;
void Notify(string notification);
string Password { get; }
string UserName { get; }
}
public interface INotify
{
void Notify(string notification);
}
public interface ILogonView : INotify
{
event EventHandler LogonEvent;
string Password { get; }
string UserName { get; }
}
Notice that ILogonView inherits from INotify. Now we need to pass this INotify to the service, so lets modify our service. Other then that little change there nothing new with our View. Lets look at the controller using the
LogonService class.
public class LogonController
{
private ILogonView mView;
public LogonController(ILogonView view)
{
// register the view
mView = view;
// listen to the view logon event
mView.LogonEvent += new EventHandler(mView_LogonEvent);
}
void mView_LogonEvent(object sender, EventArgs e)
{
string userName = mView.UserName;
string password = mView.Password;
LogonService logonService = new LogonService(mView);
logonService.Logon(userName, password);
}
}
public class LogonService
{
private INotify mNotifier;
public LogonService(INotify notifier)
{
mNotifier = notifier;
}
public bool Logon(string userName, string password)
{
bool rc;
if ((userName == "mike") && (password == "aop"))
{
mNotifier.Notify("Logon Successful");
rc = true;
}
else
{
mNotifier.Notify("Invliad User or Password");
rc = false;
}
return rc;
}
}
Notice a few things:
- The service is created as a local variable to the event handler, it might be better to create the service once and keep it during the lifetime of the controller, but then we have the problem of who creates the service? It could be passed to the controller on the constructor or created directly in the Controller, but then it might not be re-used by other controllers. Best approach is to pass the service to the constructor, but I am not willing to have the view create it and pass it. I will deal with this issue soon.
- The service is passing the view as an INotify, there is really nothing wrong with that, but if you think about it allows the service direct access to the UI. A better way would be to have the service talk to the Controller via INotify. Then we are respecting the role of the controller... (so I am going to make this change by allowing the controller to implement INotify
- The goal was to provide the service a channel to communicate to the view, so now it is possible for a long running service to report status. But, what if we don't want to report anything, and would like to run the service without a view and without a UI, notice that you can't create the service without INotify. So here is another problem, which I will deal with later. At least we can say the logic of the logon is now outside of the controller, and our service is a little more powerful with its capabilities
- A note about DDD (Domain Driven Design), notice my service is not very domain driven, it is normally at the service level that we should see classes such as User, and Authentication, For example
Autentication.Autenticate(User user)
, but I am leaving the domain model out of this article, because already I have a lot of problems to solve and DDD deserve an article on its own.
Lets do a bit of re-factoring, first step, lets create the service as a member variable of the controller class. Lets also have the ability to pass the service to the constructor
public LogonController(ILogonView view)
{
// register the view
mView = view;
// listen to the view logon event
mView.LogonEvent += new EventHandler(mView_LogonEvent);
mLogonService = new LogonService(this);
}
// set the serivce by the client (but not used for now).
public LogonController(ILogonView view, LogonService logonService)
{
// register the view
mView = view;
// listen to the view logon event
mView.LogonEvent += new EventHandler(mView_LogonEvent);
mLogonService = logonService;
}
void mView_LogonEvent(object sender, EventArgs e)
{
string userName = mView.UserName;
string password = mView.Password;
mLogonService.Logon(userName, password);
}
// used to implemnt INotify
public void Notify(string notification)
{
mView.Notify(notification);
}
}
- The controller implements INotify
- The service can be passed to the controller, it's more flexible. But I am not changing the view, so this constructor will not be used
- I created the service as a member variable if it is not passed.
OK. Lets step back a little, most of you might be happy with this implementation of MVP, and it is kind of by the book. But, I am still not happy with it. First of all, who is going to create the LogonService, it should really be set outside of the controller, or supplied to the controller (but I hate having the view create the service. Same problem with the Controller being created by the view. The view should have no knowledge of how to create a controller, it would be nice if the controller can be supplied to the view. The solution is to have a factory pattern to create the controller, service and even the view! The solution to all these problems can be addressed with Dependency Injection (DI).
The View depends on a Controller, and my Controller depends on a service. I can create a Factory that will create my view, addressing all the dependencies, and what do you know, there is a framework out there just for that - Spring.Net.
Using Spring.NET for Dependency Injection
To tell you all the truth, I am new to Dependency Injection, however it was not complicated to learn, and it solves the issues of object creation and dependecies. I chose to resolve all dependecies using setters properites. I have also created interfaces to all the classes that require depndecy injection
The controller interface:
public interface ILogonController : INotify
{
ILogonView LogonView { get; set; }
}
Setting the view to the controller, rather then passing the view as parameter into the constructure. Now the controller can have a empty default constructure.
Lets see the newly modified controller
public class LogonController : INotify, ILogonController
{
private ILogonView mView;
private ILogonService mLogonService;
public LogonController()
{
}
public LogonController(ILogonView view)
{
// register the view
mView = view;
// listen to the view logon event
mView.LogonEvent += new EventHandler(mView_LogonEvent);
mLogonService = new LogonService(this);
}
// set the serivce by the client (but not used for now).
public LogonController(ILogonView view, LogonService logonService)
{
// register the view
mView = view;
// listen to the view logon event
mView.LogonEvent += new EventHandler(mView_LogonEvent);
mLogonService = logonService;
}
void mView_LogonEvent(object sender, EventArgs e)
{
// make sure the view is attached
Debug.Assert(mView != null, "view not attached");
string userName = mView.UserName;
string password = mView.Password;
mLogonService.Logon(userName, password);
}
// used to implemnt INotify
public void Notify(string notification)
{
mView.Notify(notification);
}
public ILogonService LogonService
{
set
{
mLogonService = value;
mLogonService.Notifier = this;
}
get
{
return mLogonService;
}
}
public ILogonView LogonView
{
set
{
mView = value;
mView.LogonEvent += new EventHandler(mView_LogonEvent);
}
get
{
return mView;
}
}
}
Notes:
- Now when setting the LogonView property, the view is attached to the controller. Spring.Net will handle this for us.
- Because this controller can be created using an empty constructure, I added an Assert, to make sure the view is attached when handling the logon event handler
- Notice there is a setter property for the LogonService, After setting the service, the controller sets the Notify property of the service.
In order to inject the service into the controller, I needed to first make an interface to the service. Lets take a look at the service interface and the service class:
public interface ILogonService
{
bool Logon(string userName, string password);
INotify Notifier { get; set; }
}
public class LogonService : ILogonService
{
private INotify mNotifier;
public LogonService()
{
// instead of having it as null
mNotifier = new EmptyNotify();
}
public LogonService(INotify notifier)
{
mNotifier = notifier;
}
public bool Logon(string userName, string password)
{
bool rc;
if ((userName == "mike") && (password == "aop"))
{
mNotifier.Notify("Logon Successful");
rc = true;
}
else
{
mNotifier.Notify("Invliad User or Password");
rc = false;
}
return rc;
}
public INotify Notifier
{
set { mNotifier = value; }
get { return mNotifier; }
}
}
Notes:
- The logon service interface now allows an INotify to be specified as a setter (so if I wanted, it could of been initialized using DI as well, but I am not doing it in this example
- I provide an empty constructor, so now I don't need to have a notify object for creating or running the service. However, in case the INotify is not passed to the constructor or not set by the setter, I provide an EmptyNotify which is similar to having the mNofity set to null. However, because this empty class implements the interface, I don't need to check in my code if the INotify object is null, or if is passed.
Here a quick look at the EmptyNotify (which is like a Null)
public class EmptyNotify : INotify
{
public void Notify(string notification)
{
return; // do nothing.
}
}
Lets see how the view is changed
The nice thing about the new version of the view is that it is now simpler, the goal was always to keep the view unaware of business work and even something simple as creating the controller is now outside of the view. Like anything in MVP, there is a property to set the controller.
public ILogonController LogonController
{
set
{
mController = value;
mController.LogonView = this;
}
}
Notice that right after the controller is set, I assign the view to the controller LogonView property. Here is the full source code of the view after the modification.
public partial class LogonForm : Form, ILogonView
{
public event EventHandler LogonEvent;
private ILogonController mController;
public LogonForm()
{
InitializeComponent();
}
///
/// Get the User Name from the username text box
/// Trim the user name, and always return lower case
///
public string UserName
{
get { return mTextBoxUserName.Text.Trim().ToLower(); }
}
///
/// Get the password from the password textbox
///
public string Password
{
get
{
return mTextBoxPassword.Text;
}
}
public ILogonController LogonController
{
set
{
mController = value;
mController.LogonView = this;
}
}
///
/// Update the screen with a message
///
/// Message to show on the status bar
public void Notify(string notification)
{
mToolStripStatusLabelStatus.Text = notification;
}
private void mButtonLogon_Click(object sender, EventArgs e)
{
// fire the event that the button was clicked.
if (LogonEvent != null)
LogonEvent(this, EventArgs.Empty);
}
}
Setting Spring.Net
So far I have tried to avoid talking about Spring.Net, but at this point we are ready to deal with
Dependency Injection. First step is to get Spring.Net and install it. Don't worry it is safe, I have done it many times it will not break anything on your computer. You can download Spring.Net from http://www.springframework.net/
This example is using version 1.1 of Spring.Net
Next step is adding a file reference to Spring.Core.dll into the .NET project.
Now, that we have Spring.net, all that is left is to specify the dependencies for creating the objects (view, controller and service). To do this I have used the app.config to specify the dependencies in XML. The XML is simple to understand here is a copy of my App.Config for the Logon MVP example:
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<configSections>
<sectionGroup name="spring">
<section name="context" type="Spring.Context.Support.ContextHandler, Spring.Core"/>
<section name="objects" type="Spring.Context.Support.DefaultSectionHandler, Spring.Core" />
</sectionGroup>
</configSections><spring>
<context>
<resource uri="config://spring/objects"/>
</context><objects xmlns="http://www.springframework.net/">
<object id ="LogonService" type="MvpExample.LogonService, MvpExample"/>
<object id="LogonController" type="MvpExample.LogonController, MvpExample">
<property name="LogonService" ref="LogonService"/>
</object>
<object id="LogonView" type="MvpExample.LogonForm, MvpExample">
<property name="LogonController" ref="LogonController"/>
</object>
</objects>
</spring>
</configuration>
Notes:
All that is left, is just to create the root object, the view within my application. Notice the changes to Program.cs
static class Program
{
///
/// The main entry point for the application.
///
[STAThread]
static void Main()
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
IApplicationContext ctx = ContextRegistry.GetContext();
Form logonForm = ctx.GetObject("LogonView") as Form;
Application.Run(logonForm);
}
}
The most important part are the Spring.net application context:
IApplicationContext ctx = ContextRegistry.GetContext();
Form logonForm = ctx.GetObject("LogonView") as Form;
Here we are asking Spring.Net to create our View object, by specifying its Logical Name LogonView, the name must match the name in the app.config
<object id="LogonView" type="MvpExample.LogonForm, MvpExample">
<property name="LogonController" ref="LogonController"/>
</object>
All the other dependencies are taken care of by using Spring.Net. At this point our Logon application is initialized with the correct version of a view, controller and service. Notice that this allows us to "switch" the implementation of a dependency, just by providing a new implementation of an interface and configuration change. For example, to provide another version of the LogonService that willcontain
additional work, we just need to tell Spring.Net which version to "inject". At this point I wanted to finish my article, but I noticed that there is one more issue with this MVP
pattern. The case of having a long running service, and the risk of freezing the UI while processing the service. To solve this problem I have introduced a simple but yet powerful little threading framework.
The UI should never freeze
The main problem with UI and threads, is that the UI is not allowed to accessible from any other the thread then the thread it was created. This means that if our application starts to create threads, and performs processing in threads, it is not legal to have these threads update the UI (via an interface or not). In fact, trying to update the UI from another thread will cause a runtime exception in .NET 2.0, and unpredictable results in version 1.1
So, our first goal is to make sure the UI is able to update correctly no matter from which thread it is called on. To do this, I have added a base class to my LogonForm called View. My base class contains only one method, UpdateUI, this method accepts a delegate of type MethodInvoker and makes sure this delegate is executed on the UI thread.
public class View : Form
{
protected void UpdateUI(MethodInvoker uiDelegate)
{
if (InvokeRequired)
this.Invoke(uiDelegate);
else
uiDelegate();
}
}
I am planning to use the same delegate for all my UI activities. This should make you wonder, how a delegate that takes no arguments and no return values is able to satisfy all UI operations.
Here is a trick... I use Anonymous methods to wrap all UI operations... lets look at a simple example:
Getting the user name from the UI should be done on the UI thread, so I would like to wrap the code that gets the user name from the textbox into a MethodInvoker delegate
Before:
public string UserName
{
get
{
return mTextBoxUserName.Text.Trim().ToLower();
}
}
After:
public string UserName
{
get
{
string value = null;
MethodInvoker uiDelegate = delegate
{
value = mTextBoxUserName.Text.Trim().ToLower();
};
UpdateUI(uiDelegate);
return value;
}
}
Notice: for the getters, I needed to store the return value outside of my anonymous method. This is because my delegate does not accept a return value. This is an issue I plan to resolved in a second part of the article... (using AOP to handle UI thread safely). Now, we have to surround all our View public methods with this uiDelegate. To make the job simpler I made a C# Snippet that allows you to select the code within the property, and then apply the "view thread safe"
snippet. Here is the snippet, if you want to use it.
<?xml version="1.0" encoding="utf-8" ?>
<CodeSnippets xmlns="http://schemas.microsoft.com/VisualStudio/2005/CodeSnippet">
<CodeSnippet Format="1.0.0">
<Header>
<Title>View Thread Safe</Title>
<Shortcut>view</Shortcut>
<Description>Code snippet for creating thread safe view code</Description>
<Author>Atrion Corporation</Author>
<SnippetTypes>
<SnippetType>Expansion</SnippetType>
<SnippetType>SurroundsWith</SnippetType>
</SnippetTypes>
</Header>
<Snippet>
<Declarations>
<Literal>
<ID>delegate</ID>
<ToolTip>Delegate to call</ToolTip>
<Default>uiDelegate</Default>
</Literal>
<Literal>
<ID>method</ID>
<ToolTip>Function to handle the threading</ToolTip>
<Default>UpdateUI</Default>
</Literal>
</Declarations>
<Code Language="csharp"><![CDATA[
MethodInvoker $delegate$ = delegate
{
$selected$ $end$
};
$method$($delegate$);
$end$]]>
</Code>
</Snippet>
</CodeSnippet>
</CodeSnippets>
Lets look at our new View method:
public partial class LogonForm : View, ILogonView
{
public event EventHandler LogonEvent;
private ILogonController mController;
public LogonForm()
{
InitializeComponent();
//mController = new LogonController(this);
}
///
/// Get the User Name from the username text box
/// Trim the user name, and always return lower case
///
public string UserName
{
get
{
string value = null;
MethodInvoker uiDelegate = delegate
{
value = mTextBoxUserName.Text.Trim().ToLower();
};
UpdateUI(uiDelegate);
return value;
}
}
///
/// Get the password from the password textbox
///
public string Password
{
get
{
string value = null;
MethodInvoker uiDelegate = delegate
{
value = mTextBoxPassword.Text;
};
UpdateUI(uiDelegate);
return value;
}
}
public ILogonController LogonController
{
set
{
mController = value;
mController.LogonView = this;
}
}
///
/// Update the screen with a message
///
/// Message to show on the status bar
public void Notify(string notification)
{
MethodInvoker uiDelegate = delegate
{
mToolStripStatusLabelStatus.Text = notification;
};
UpdateUI(uiDelegate);
}
private void mButtonLogon_Click(object sender, EventArgs e)
{
// fire the event that the button was clicked.
if (LogonEvent != null)
LogonEvent(this, EventArgs.Empty);
}
}
Now our view is able to update itself, no matter from which thread it is being called from. Next step is to actually use threads within the controller. Suppose the logon takes 5 seconds to logon. To enable threading at the controller level I can use the same approach I used for the view. Using delegates... Notice the new base controller class.
public class AsyncController
{
public delegate void AsyncDelegate();
// must call end invoke to clean up resources by the .net runtime.
// if there is an exception, call the OnExcption which may be overridden by
// children.
protected void EndAsync(IAsyncResult ar)
{
// clean up only.
AsyncDelegate del = (AsyncDelegate)ar.AsyncState;
try
{
del.EndInvoke(ar);
}
catch (Exception ex)
{
OnException(ex);
}
}
protected void BeginInvoke(AsyncDelegate del)
{
// thread the delegate, as a fire and forget.
del.BeginInvoke(EndAsync, del);
}
protected virtual void OnException(Exception ex)
{
// override by childern
}
}
Notes:
- By calling BeginInvoke on a delegate I am using the thread-pool
- I don't really care for output values or return code and this is mostly due to the MVP pattern, when I implement my controller function I can know when to set values to the view.
- Notice that I still make sure EndInvoke is called; this is for 2 reasons, first to make sure I get exceptions and second making sure there is no resource leak. Calling BeginInvoke without EndInvoke may cause resources to leak.
- If there is an exception, I let the child controller to take care of it.
I modified the logon service to simulate a long running operation, by Sleeping for 5 seconds
public bool Logon(string userName, string password)
{
// simulate a long operation, wait 5 seconds.
System.Threading.Thread.Sleep(TimeSpan.FromSeconds(5));
bool rc;
if ((userName == "mike") && (password == "aop"))
{
mNotifier.Notify("Logon Successful");
rc = true;
}
else
{
mNotifier.Notify("Invliad User or Password");
rc = false;
}
return rc;
}
Lets, see the new logon event handler using threads:
void mView_LogonEvent(object sender, EventArgs e)
{
// make sure the view is attached
Debug.Assert(mView != null, "view not attached");
AsyncDelegate asyncOperation = delegate
{
mView.Notify("About to perform logon");
string userName = mView.UserName;
string password = mView.Password;
mLogonService.Logon(userName, password);
};
base.BeginInvoke(asyncOperation);
}
Just one note:
Now when you execute the program, the UI will not freeze when pressing logon (the way it should be). However that doesn't stop the user from keep pressing on the logon button. It is important to add to the view additional functions to enable and disable the button. I didn't do it in this example, however it works well with MVP, but providing a setter method to enable or disable the Logon Button.
Conclusion
We went a long way from our simple Logon application. We have removed most of the logic from the view, keeping the view very simple. We have used DI to allow the application to inject the controller and service into our view. Finally, I have showed a way to enable threading without making major changes. The idea is to wrap UI functions with thread safe code, allowing all UI code to be marshalled to the UI thread. The only last re-factoring left to do is to remove the thread safety wrappers, and find a way to have them done with an Advice using Spring.Net. That way we can keep the view simple, not even knowing about the threading work.
However, I think this will be done in my next article. I hope you enjoyed this article - Happy .netting.
Dowload sample code: http://mike.peretz.googlepages.com/MvpProject.zip
4 comments:
cool! ;}
well done! Its the first article i read about with clear example and no missing code :)
would kick it :)
greetz
blackhat
Nice article. I'm interested in using MVP in an ASP.Net web app that makes calls to several web services. It's using async pages and async tasks now to speed up the application. If we went with an MVP approach, would there be anyway to use the built-in ASP.Net async capabilities (e.g. setting page timeout), or would we have to manage that ourselves?
Again, nice article. I really appreciate the detail and step-by-step approach.
very nice article! It boosts my MVP and DI skills like no other did. Thanks und Regards
Post a Comment