Services

As our module grows the controllers can become quite bloated. It is best practice to move all of our business logic into it's own service so the controller is kept clean and only focuses on it's own concerns. This is known as separation of concerns.

Let's refactor our module and add a service for our business logic. Services also have the advantage that we can create common methods which can be called throughout our module to save us from repeating ourselves.

Start by adding a folder called "Services" within our module. Within that folder create the following file called "IShopService.cs":

using System.Collections.Generic;
using System.ComponentModel;
using System.Threading.Tasks;
using DemoShop.Shop.Models;
using Kit.Collections.Generic;

namespace DemoShop.Shop.Services;

public interface IShopService {
    #region Brand Members

    Task<IList<Brand>> GetBrandsAsync(bool isActive);
    Task<ISortedPagedList<Brand>> GetBrandsAsync(bool isActive, int page, int pageSize, string? sortBy = null, ListSortDirection? sortDirection = null);
    Task<Brand?> GetBrandAsync(int id);
    Task InsertBrandAsync(Brand brand);
    Task UpdateBrandAsync(Brand brand);
    Task DeleteBrandAsync(Brand brand);

    #endregion

    #region Product Members

    Task<IList<Product>> GetProductsAsync();
    Task<ISortedPagedList<Product>> GetProductsAsync(int page, int pageSize, string? sortBy = null, ListSortDirection? sortDirection = null);
    Task<Product?> GetProductAsync(int id);
    Task InsertProductAsync(Product product);
    Task UpdateProductAsync(Product product);
    Task DeleteProductAsync(Product product);

    #endregion
}

Note: This file is a kind of contract that your service must implement. We only take dependencies on the contract, which allows us to create multiple implementations. This gives us the ability to override existing services or create fake ones during testing.

Now create a file called "ShopService.cs" alongside the interface:

using System.Collections.Generic;
using System.ComponentModel;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using DemoShop.Shop.Models;
using Kit.Collections.Generic;
using Kit.Data;
using Kit.Settings;
using Microsoft.AspNetCore.Hosting;

namespace DemoShop.Shop.Services;

public class ShopService : IShopService {
    private readonly IDataContext _dataContext;
    private readonly IWebHostEnvironment _environment;
    private readonly ISiteSettings _siteSettings;

    public ShopService(IDataContext dataContext, IWebHostEnvironment environment, ISiteSettings siteSettings) {
        _dataContext = dataContext;
        _environment = environment;
        _siteSettings = siteSettings;
    }

    #region Brand Members

    public virtual async Task<IList<Brand>> GetBrandsAsync(bool isActive) {
        return await _dataContext.Repository<Brand>().All().Where(b => b.IsActive == isActive).ToSortedListAsync(b => b.Name, ListSortDirection.Ascending);
    }

    public virtual async Task<ISortedPagedList<Brand>> GetBrandsAsync(bool isActive, int page, int pageSize, string? sortBy = null, ListSortDirection? sortDirection = null) {
        return await _dataContext.Repository<Brand>().All()
            .Where(b => b.IsActive == isActive)
            .ToSortedPagedListAsync(page, pageSize, sortBy ?? "Name", sortDirection ?? ListSortDirection.Ascending);
    }

    public virtual async Task<Brand?> GetBrandAsync(int id) {
        return await _dataContext.Repository<Brand>().GetAsync(id);
    }

    public virtual async Task InsertBrandAsync(Brand brand) {
        // Insert the brand.
        await _dataContext.Repository<Brand>().InsertOrUpdateAsync(brand);
    }

    public virtual Task UpdateBrandAsync(Brand brand) {
        return Task.CompletedTask;
    }

    public virtual async Task DeleteBrandAsync(Brand brand) {
        // Delete the brand.
        await _dataContext.Repository<Brand>().DeleteAsync(brand);
    }

    #endregion

    #region Product Members

    public virtual async Task<IList<Product>> GetProductsAsync() {
        return await _dataContext.Repository<Product>().All().ToSortedListAsync(p => p.Name, ListSortDirection.Ascending);
    }

    public virtual async Task<ISortedPagedList<Product>> GetProductsAsync(int page, int pageSize, string? sortBy = null, ListSortDirection? sortDirection = null) {
        return await _dataContext.Repository<Product>().All().Fetch(p => p.Brand).ToSortedPagedListAsync(page, pageSize, sortBy ?? "Name", sortDirection ?? ListSortDirection.Ascending);
    }

    public virtual async Task<Product?> GetProductAsync(int id) {
        return await _dataContext.Repository<Product>().GetAsync(id);
    }

    public virtual async Task InsertProductAsync(Product product) {
        // Insert the product.
        await _dataContext.Repository<Product>().InsertOrUpdateAsync(product);
    }

    public virtual Task UpdateProductAsync(Product product) {
        return Task.CompletedTask;
    }

    public virtual async Task DeleteProductAsync(Product product) {
        // Delete the product.
        await _dataContext.Repository<Product>().DeleteAsync(product);

        // Delete the image (if applicable).
        if (!string.IsNullOrEmpty(product.Image))
            File.Delete(Path.Combine(_siteSettings.GetSiteAssetsPath(_environment.WebRootPath), "products", product.Image));
    }

    #endregion
}

Note: Like we did with our controller actions we group our service methods by the entity model inside a region block.

Now we must register our service so that we can inject it in our controllers. Create a file called "HostingStartup.cs" inside the root of our project with the following content:

using DemoShop.Shop.Services;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;

[assembly: HostingStartup(typeof(DemoShop.Shop.HostingStartup))]

namespace DemoShop.Shop;

public class HostingStartup : IHostingStartup {
    public void Configure(IWebHostBuilder builder) {
        builder.ConfigureServices((context, services) => {
            services.AddTransient<IShopService, ShopService>();
        });
    }
}

Now we just have to update our controllers to make use of our service. Open the "HomeController.cs" file and replace it with the following:

using System.Linq;
using System.Threading.Tasks;
using DemoShop.Shop.Services;
using DemoShop.Shop.ViewModels.Home;
using Microsoft.AspNetCore.Mvc;

namespace DemoShop.Shop.Controllers;

[Area("DemoShop.Shop"), Route("shop")]
public class HomeController : Controller {
    private readonly IShopService _shopService;

    public HomeController(IShopService shopService) {
        _shopService = shopService;
    }

    [HttpGet]
    public async Task<IActionResult> Index(int page = 1) {
        return View((await _shopService.GetProductsAsync(page, 15)).Convert(p => new ProductViewModel(p)));
    }

    [HttpGet("{id:int}")]
    public async Task<IActionResult> Details(int id) {
        var product = await _shopService.GetProductAsync(id);

        // Make sure the product exists.
        if (product == null)
            return NotFound();

        return View(new ProductViewModel(product, true));
    }
}

Finally replace the "AdminController.cs" file with the following:

using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
using DemoShop.Shop.Models;
using DemoShop.Shop.Services;
using DemoShop.Shop.ViewModels.Admin;
using Kit.Data;
using Kit.Mvc.Filters;
using Kit.Security;
using Kit.Security.Services;
using Kit.Settings;
using Kit.Themes;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Localization;

namespace DemoShop.Shop.Controllers;

[Area("DemoShop.Shop"), Route("admin")]
public class AdminController : Controller {
    private readonly IAuthorizationService _authorizationService;
    private readonly IDataContext _dataContext;
    private readonly IWebHostEnvironment _environment;
    private readonly IStringLocalizer _localizer;
    private readonly IShopService _shopService;
    private readonly ISiteSettings _siteSettings;
    private readonly IThemeManager _themeManager;

    public AdminController(IAuthorizationService authorizationService, IDataContext dataContext, IWebHostEnvironment environment, IStringLocalizer localizer, IShopService shopService, ISiteSettings siteSettings, IThemeManager themeManager) {
        _authorizationService = authorizationService;
        _dataContext = dataContext;
        _environment = environment;
        _localizer = localizer;
        _shopService = shopService;
        _siteSettings = siteSettings;
        _themeManager = themeManager;
    }

    #region Brand Members

    [HasPermission(Permission.Brands), HttpGet("brands")]
    public async Task<IActionResult> Brands(bool isActive = true, int page = 1, int pageSize = 10, string? sortBy = null, ListSortDirection? sortDirection = null) {
        return View((await _shopService.GetBrandsAsync(isActive, page, pageSize, sortBy, sortDirection)).Convert(b => new BrandViewModel(b)));
    }

    [HasPermission(Permission.Brands), HttpPost("brands"), ValidateAntiForgeryToken]
    public async Task<IActionResult> Brands(string command, int? id, bool isActive = true, int page = 1, int pageSize = 10, string? sortBy = null, ListSortDirection? sortDirection = null) {
        if (id.HasValue) {
            switch (command) {
                case "Delete":
                    try {
                        // Try to delete the brand.
                        await _shopService.DeleteBrandAsync((await _shopService.GetBrandAsync(id.Value))!);
                        await _dataContext.CommitAsync();

                        // Set the result text.
                        TempData.Set("FormResult", _localizer["{0} successfully deleted.", _localizer["Brand"]].Value);
                    } catch {
                        // Add the error.
                        ModelState.AddModelError("Error", _localizer["{0} cannot be deleted.", _localizer["Brand"]].Value);

                        // Rollback the changes.
                        await _dataContext.RollbackAsync();

                        return await Brands(isActive, page, pageSize, sortBy, sortDirection);
                    }

                    break;
                case "Edit":
                    return RedirectToAction(nameof(EditBrand), new { id, returnUrl = Url.Action(nameof(Brands), new { isActive, page, pageSize, sortBy, sortDirection }) });
            }
        }

        return RedirectToAction(nameof(Brands), new { page = 1, pageSize, sortBy, sortDirection });
    }

    [HasPermission(Permission.Brands), HttpGet("brands/new")]
    public IActionResult NewBrand() {
        return View(new BrandFormViewModel(new Brand()));
    }

    [HasPermission(Permission.Brands), HttpPost("brands/new"), ValidateAntiForgeryToken]
    public async Task<IActionResult> NewBrand(BrandFormViewModel model) {
        // Validate the form.
        if (!ModelState.IsValid)
            return View(model.Initialize(new Brand()));

        // Insert the brand.
        await _shopService.InsertBrandAsync(model.ToModel());
        await _dataContext.CommitAsync();

        // Set the result text.
        TempData.Set("FormResult", _localizer["{0} successfully added.", _localizer["Brand"]].Value);

        return RedirectToAction(nameof(Brands));
    }

    [HasPermission(Permission.Brands), HttpGet("brands/{id:int}/edit")]
    public async Task<IActionResult> EditBrand(int id) {
        return View(new BrandFormViewModel((await _shopService.GetBrandAsync(id))!));
    }

    [HasPermission(Permission.Brands), HttpPost("brands/{id:int}/edit"), ValidateAntiForgeryToken]
    public async Task<IActionResult> EditBrand(int id, BrandFormViewModel model, string? returnUrl = null) {
        var brand = (await _shopService.GetBrandAsync(id))!;

        // Validate the form.
        if (!ModelState.IsValid)
            return View(model.Initialize(brand));

        // Update the brand.
        await _shopService.UpdateBrandAsync(model.ToModel(brand));
        await _dataContext.CommitAsync();

        // Set the result text.
        TempData.Set("FormResult", _localizer["{0} successfully updated.", _localizer["Brand"]].Value);

        if (Url.IsLocalUrl(returnUrl))
            return Redirect(returnUrl);
        else
            return RedirectToAction(nameof(Brands));
    }

    #endregion

    #region Product Members

    [HasPermission(Permission.Products), HttpGet("products")]
    public async Task<IActionResult> Products(int page = 1, int pageSize = 10, string? sortBy = null, ListSortDirection? sortDirection = null) {
        var timeZone = await HttpContext.GetTimeZoneAsync();

        return View((await _shopService.GetProductsAsync(page, pageSize, sortBy, sortDirection)).Convert(p => new ProductViewModel(p, timeZone)));
    }

    [HasPermission(Permission.Products), HttpPost("products"), ValidateAntiForgeryToken]
    public async Task<IActionResult> Products(string command, int? id, int page = 1, int pageSize = 10, string? sortBy = null, ListSortDirection? sortDirection = null) {
        if (id.HasValue) {
            switch (command) {
                case "Delete":
                    try {
                        // Make sure they are allowed.
                        if (!_authorizationService.IsAllowed((await HttpContext.GetUserAsync())!, Permission.ProductsDelete))
                            return Unauthorized();

                        // Try to delete the product.
                        await _shopService.DeleteProductAsync((await _shopService.GetProductAsync(id.Value))!);
                        await _dataContext.CommitAsync();

                        // Set the result text.
                        TempData.Set("FormResult", _localizer["{0} successfully deleted.", _localizer["Product"]].Value);
                    } catch {
                        // Add the error.
                        ModelState.AddModelError("Error", _localizer["{0} cannot be deleted.", _localizer["Product"]].Value);

                        // Rollback the changes.
                        await _dataContext.RollbackAsync();

                        return await Products(page, pageSize, sortBy, sortDirection);
                    }

                    break;
                case "Edit":
                    return RedirectToAction(nameof(EditProduct), new { id, returnUrl = Url.Action(nameof(Products), new { page, pageSize, sortBy, sortDirection }) });
            }
        }

        return RedirectToAction(nameof(Products), new { page = 1, pageSize, sortBy, sortDirection });
    }

    [HasPermission(Permission.Products), HttpGet("products/new")]
    public async Task<IActionResult> NewProduct() {
        return View(new ProductFormViewModel(new Product(), await _shopService.GetBrandsAsync(true), await _shopService.GetProductsAsync(), _themeManager.GetLayoutTheme("/").PageLayouts));
    }

    [HasPermission(Permission.Products), HttpPost("products/new"), ValidateAntiForgeryToken]
    public async Task<IActionResult> NewProduct(ProductFormViewModel model) {
        // Validate the form.
        if (!ModelState.IsValid)
            return View(model.Initialize(new Product(), await _shopService.GetBrandsAsync(true), _themeManager.GetLayoutTheme("/").PageLayouts));

        // Insert the product.
        await _shopService.InsertProductAsync(model.ToModel(_siteSettings.GetSiteAssetsPath(_environment.WebRootPath)));
        await _dataContext.CommitAsync();

        // Set the result text.
        TempData.Set("FormResult", _localizer["{0} successfully added.", _localizer["Product"]].Value);

        return RedirectToAction(nameof(Products));
    }

    [HasPermission(Permission.Products), HttpGet("products/{id:int}/edit")]
    public async Task<IActionResult> EditProduct(int id) {
        return View(new ProductFormViewModel((await _shopService.GetProductAsync(id))!, await _shopService.GetBrandsAsync(true), await _shopService.GetProductsAsync(), _themeManager.GetLayoutTheme("/").PageLayouts));
    }

    [HasPermission(Permission.Products), HttpPost("products/{id:int}/edit"), ValidateAntiForgeryToken]
    public async Task<IActionResult> EditProduct(int id, ProductFormViewModel model, string? returnUrl = null) {
        var product = (await _shopService.GetProductAsync(id))!;

        // Validate the form.
        if (!ModelState.IsValid)
            return View(model.Initialize(product, await _shopService.GetBrandsAsync(true), _themeManager.GetLayoutTheme("/").PageLayouts));

        // Update the product.
        await _shopService.UpdateProductAsync(model.ToModel(product, _siteSettings.GetSiteAssetsPath(_environment.WebRootPath)));
        await _dataContext.CommitAsync();

        // Set the result text.
        TempData.Set("FormResult", _localizer["{0} successfully updated.", _localizer["Product"]].Value);

        if (Url.IsLocalUrl(returnUrl))
            return Redirect(returnUrl);
        else
            return RedirectToAction(nameof(Products));
    }

    #endregion
}

Providers »