Model-View-Presenter and its implementation in ASP.NET - Part 4
In this post, we will once again implement the MVP pattern, this time with a more sophisticated example.
We will follow a similar approach as in the previous post, progressively modifying the existing Weather page. However, this time we will not go into as much detail for each step.
Remember that the Weather page simulates a call to a third-party API to display an array of weather forecasts.
Adding the model
Add a Models repository in our solution.
Add a WeatherForecast.cs file and copy the following code in it.
1public class WeatherForecast
2{
3 public DateOnly Date { get; set; }
4 public int TemperatureC { get; set; }
5 public string? Summary { get; set; }
6 public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
7}
Here, we are simply moving the domain logic into a dedicated class to facilitate future development. It will also serve to illustrate the model component in the MVP pattern.
Moving the retrieval of the weather forecast to a dedicated repository
To streamline testing, we will move the retrieval logic into a dedicated repository. This approach is a common best practice and not specific to the MVP pattern.
In the Models repository, add a IWeatherForecastRepository.cs file and a WeatherForecastRepository.cs file.
Add the following code in these files.
1public interface IWeatherForecastRepository
2{
3 Task<List<WeatherForecast>> LoadWeatherForecasts();
4}
1public class WeatherForecastRepository : IWeatherForecastRepository
2{
3 public async Task<List<WeatherForecast>> LoadWeatherForecasts()
4 {
5 var startDate = DateOnly.FromDateTime(DateTime.Now);
6 var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" };
7 return Enumerable.Range(1, 5).Select(index => new WeatherForecast
8 {
9 Date = startDate.AddDays(index),
10 TemperatureC = Random.Shared.Next(-20, 55),
11 Summary = summaries[Random.Shared.Next(summaries.Length)]
12 }).ToList();
13 }
14}
There is nothing revolutionary in this approach; we are simply separating different concepts into distinct files.
Adding the view
Duplicate the existing Weather.razor file and rename the copy to WeatherModified.razor.
Add a new class and name it WeatherModified.razor.cs.
In the WeatherModified.razor.cs file, add the following code.
1public partial class WeatherModifiedView : ComponentBase
2{
3 [Inject]
4 protected IWeatherForecastRepository WeatherForecastRepository { get; set; }
5
6 protected WeatherModifiedPresenter _presenter;
7
8 public bool IsLoading { get; set; }
9
10 public List<WeatherForecastDto> Forecasts { get; set; } = new List<WeatherForecastDto>();
11
12 protected override async Task OnInitializedAsync()
13 {
14 _presenter = new WeatherModifiedPresenter(this, WeatherForecastRepository);
15 await _presenter.Initialize();
16 }
17
18 public void SetIsLoading(bool isLoading)
19 {
20 IsLoading = isLoading;
21 }
22
23 public void FillWeatherForecasts(List<WeatherForecastDto> forecasts)
24 {
25 Forecasts = forecasts;
26 }
27}
- In the WeatherModified.razor file, add the following code.
1<PageTitle>Weather</PageTitle>
2
3<h1>Weather</h1>
4
5<p>This component demonstrates showing data.</p>
6@if (IsLoading)
7{
8 <p><em>Loading...</em></p>
9}
10else
11{
12 <table class="table">
13 <thead>
14 <tr>
15 <th>Date</th>
16 <th>Temp. (C)</th>
17 <th>Temp. (F)</th>
18 <th>Summary</th>
19 </tr>
20 </thead>
21 <tbody>
22 @foreach (var forecast in Forecasts)
23 {
24 <tr>
25 <td>@forecast.Date.ToShortDateString()</td>
26 <td>@forecast.TemperatureC</td>
27 <td>@forecast.TemperatureF</td>
28 <td>@forecast.Summary</td>
29 </tr>
30 }
31 </tbody>
32 </table>
33}
As before, the view is merely a simple object that delegates the business logic to the presenter. Note that, in the code above, some classes have not yet been defined, notably the presenter and the WeatherForecastDto. We will introduce them later.
Adding the presenter
The final step is to add the most crucial piece of code that will enable us to orchestrate all the components.
- Add in the WeatherModified.razor.cs file a new class named WeatherModifiedPresenter with the following code in it.
1public class WeatherModifiedPresenter
2{
3 private WeatherModifiedView _view;
4 private IWeatherForecastRepository _weatherForecastRepository;
5
6 public WeatherModifiedPresenter(WeatherModifiedView view, IWeatherForecastRepository weatherForecastRepository)
7 {
8 _view = view;
9 _weatherForecastRepository = weatherForecastRepository;
10 }
11
12 public List<WeatherForecastDto>? Forecasts { get; private set; }
13
14 public async Task Initialize()
15 {
16 _view.SetIsLoading(true);
17
18 await Task.Delay(500);
19
20 var forecasts = await _weatherForecastRepository.LoadWeatherForecasts().ConfigureAwait(false);
21 Forecasts = forecasts.Select(x => WeatherForecastDto.FromWeatherForecast(x)).ToList();
22
23 _view.FillWeatherForecasts(Forecasts);
24
25 _view.SetIsLoading(false);
26 }
27
28 public class WeatherForecastDto
29 {
30 public DateOnly Date { get; set; }
31 public int TemperatureC { get; set; }
32 public string? Summary { get; set; }
33 public int TemperatureF { get; set; }
34
35 public static WeatherForecastDto FromWeatherForecast(WeatherForecast forecast)
36 {
37 return new WeatherForecastDto()
38 {
39 Date = forecast.Date,
40 TemperatureC = forecast.TemperatureC,
41 Summary = forecast.Summary,
42 TemperatureF = forecast.TemperatureF
43 };
44 }
45 }
46}
This code is straightforward and ensures that data is loaded from the repository when the presenter is initialized by the view. It retrieves the forecasts and translates them into a DTO object for the view.
Everything is now in place to test the business logic without resorting to sophisticated UI testing tools.
Testing the application
In the EOCS.ModelViewPresenter.UI.Tests project, add a new class named WeatherModifiedPresenterTests.cs.
Add the following test in this class.
1[Test]
2public async Task Check_WeatherForecastsAreLoaded_WhenInitialized()
3{
4 // Arrange
5 var repository = new MockWithTwoForecastsWeatherForecastRepository();
6 var view = new WeatherModifiedView();
7 var presenter = new WeatherModifiedPresenter(view, repository);
8
9 // Act
10 await presenter.Initialize().ConfigureAwait(false);
11
12 // Assert
13 Assert.AreEqual(2, presenter.Forecasts.Count);
14 Assert.AreEqual(2, view.Forecasts.Count);
15 Assert.IsFalse(view.IsLoading);
16 Assert.IsTrue(view.IsForecastsVisible);
17}
This test allows us to verify that the Initialize method functions as expected. At the same time, we check whether the view correctly displays the forecasts.
We can see here why it was essential to create a repository with all the necessary components: it enables us to streamline testing by injecting a mock class.
We can now verify that this test passes and that everything functions as it did before the refactoring.
Adding a new test
As we did previously, we'll add a new test to evaluate how easily the business logic can be verified. In our case, what should happen if the repository retrieves no data? Ideally, we want to display a straightforward message indicating that no results were found.
- Add a new test in the WeatherModifiedPresenterTests class.
1[Test]
2public async Task Check_WeatherNoForecastsAreLoaded_WhenInitialized()
3{
4 // Arrange
5 var repository = new MockWithoutForecastsWeatherForecastRepository();
6 var view = new WeatherModifiedView();
7 var presenter = new WeatherModifiedPresenter(view, repository);
8
9 // Act
10 await presenter.Initialize().ConfigureAwait(false);
11
12 // Assert
13 Assert.AreEqual(0, presenter.Forecasts.Count);
14 Assert.AreEqual(0, view.Forecasts.Count);
15 Assert.IsFalse(view.IsLoading);
16 Assert.IsFalse(view.IsForecastsVisible);
17}
We have created a dedicated repository that returns no results, initialized the presenter, and performed some assertions. As expected, the test fails because the presenter and view code have not yet been updated to handle this scenario.
- Modify the Initialize method in the WeatherModifiedPresenter class.
1public async Task Initialize()
2{
3 _view.SetIsLoading(true);
4
5 await Task.Delay(500);
6
7 var forecasts = await _weatherForecastRepository.LoadWeatherForecasts().ConfigureAwait(false);
8 Forecasts = forecasts.Select(x => WeatherForecastDto.FromWeatherForecast(x)).ToList();
9
10 _view.FillWeatherForecasts(Forecasts);
11
12 _view.SetForecastsVisible(Forecasts.Any() ? true : false);
13
14 _view.SetIsLoading(false);
15}
- Modify the WeatherModifiedView class.
1public partial class WeatherModifiedView : ComponentBase
2{
3 [Inject]
4 protected IWeatherForecastRepository WeatherForecastRepository { get; set; }
5
6 protected WeatherModifiedPresenter _presenter;
7
8 public bool IsLoading { get; set; }
9
10 public bool IsForecastsVisible { get; set; }
11
12 public List<WeatherForecastDto> Forecasts { get; set; } = new List<WeatherForecastDto>();
13
14 protected override async Task OnInitializedAsync()
15 {
16 _presenter = new WeatherModifiedPresenter(this, WeatherForecastRepository);
17 await _presenter.Initialize();
18 }
19
20 public void SetIsLoading(bool isLoading)
21 {
22 IsLoading = isLoading;
23 }
24
25 public void SetForecastsVisible(bool isForecastsVisible)
26 {
27 IsForecastsVisible = isForecastsVisible;
28 }
29
30 public void FillWeatherForecasts(List<WeatherForecastDto> forecasts)
31 {
32 Forecasts = forecasts;
33 }
34}
- Modify the WeatherModified.razor file.
1@page "/weathermodified"
2@attribute [StreamRendering]
3@using EOCS.ModelViewPresenter.UI.Components.Pages
4@inherits WeatherModifiedView
5
6<PageTitle>Weather</PageTitle>
7
8<h1>Weather</h1>
9
10<p>This component demonstrates showing data.</p>
11@if (IsLoading)
12{
13 <p><em>Loading...</em></p>
14}
15else
16{
17 if (IsForecastsVisible)
18 {
19 <table class="table">
20 <thead>
21 <tr>
22 <th>Date</th>
23 <th>Temp. (C)</th>
24 <th>Temp. (F)</th>
25 <th>Summary</th>
26 </tr>
27 </thead>
28 <tbody>
29 @foreach (var forecast in Forecasts)
30 {
31 <tr>
32 <td>@forecast.Date.ToShortDateString()</td>
33 <td>@forecast.TemperatureC</td>
34 <td>@forecast.TemperatureF</td>
35 <td>@forecast.Summary</td>
36 </tr>
37 }
38 </tbody>
39 </table>
40 }
41 else
42 {
43 <p>There is nothing to display.</p>
44 }
45}
Final thoughts
The MVP pattern enables us to separate business logic from the user interface, making it easier to streamline UI tests that don't involve layout-specific elements. We demonstrated this pattern with two examples, highlighting how straightforward it is to implement in modern frameworks.
However, despite significant improvements in maintainability and testability, we must acknowledge that this paradigm has a drawback: it often involves a lot of boilerplate code. Every time the view needs to be updated, a dedicated method in the presenter must be called, such as setButtonWithMoreFeaturesEnabled(true) or SetForecastsVisible(true). While our example remains simple, in a real-world application with dozens of components, this can lead to highly verbose code. The MVVM pattern was introduced to address this issue and eliminate the need for writing excessive boilerplate code. However, it also has its downsides—most notably, it requires significant resources. We will explore this in more detail in a future series.
The subsequent textbooks prove useful for concluding this series.