.NET on AWS Blog

Bob’s Used Books: A .NET Sample Application – Part 2: Architecture

Introduction

Welcome to the second post in the Bob’s Used Books blog post series. In the first post I discussed how to get started with Bob’s Used Books and described the different debug and deployment modes you can use to test and run the application. In this post I will dive into the architecture of Bob’s Used Books and provide some insight into the decisions that were made whilst building the sample application.

This post references v1.0.0 of Bob’s Used Books. The Bob’s Used Books v1.0.0 GitHub repository can be found here.

Overview

Bob’s Used Books is a monolithic ASP.NET Core MVC application. It consists of three projects (Bookstore.Web, Bookstore.Domain, Bookstore.Data) and four logical layers (Presentation, Application, Domain, Infrastructure). The Presentation layer is implemented in the Bookstore.Web project, the Application and Domain layers are implemented in the Bookstore.Domain project, and the Infrastructure layer is implemented in the Bookstore.Data project.

An architecture diagram that shows the dependencies between the Bookstore.Web, Bookstore.Domain, and Bookstore.Data projects.

Bob’s Used Books also contains a fourth project called Bookstore.Cdk. The Bookstore.Cdk project takes advantage of the AWS Cloud Development Kit (AWS CDK) to provision AWS services and deploy Bob’s Used Books to an Amazon Elastic Compute Cloud (Amazon EC2) instance. I won’t be focusing on the Bookstore.Cdk project in this post.

The application architecture is guided by the principles of the Clean Architecture pattern. A detailed discussion of Clean Architecture is beyond the scope of this post, but at a high level it is really about separation of concerns and dependency management. The diagram above describes the dependencies between the projects. Bookstore.Web is dependent on Bookstore.Domain and Bookstore.Data, Bookstore.Data is dependent on Bookstore.Domain, and Bookstore.Domain is not dependent on anything.

Although Bookstore.Web has a dependency on Bookstore.Data it is only used to wire up the application for dependency injection. Other than that, Bookstore.Web does not interact with Bookstore.Data directly.

Let’s take a closer look at each of the projects.

Bookstore.Web Project

The Bookstore.Web project contains the Presentation layer and is implemented with an ASP.NET Core 6.0 MVC project that uses the new minimal hosting model for bootstrapping and configuration. The minimal hosting model is designed to reduce boilerplate code, however we found that the program.cs file quickly grew to hundreds of lines of code and become difficult to understand. We decided to split the logic in program.cs into separate discrete files that are contained in the Startup folder. Let’s discuss the responsibilities of each of these startup files.

AuthenticationSetup.cs

AuthenticationSetup.cs is responsible for configuring the authentication and authorization mechanisms for Bob’s Used Books. The application uses Amazon Cognito for identity management, however this is swapped out for a mock identity implementation when using Local Debugging.

ConfigurationSetup.cs

ConfigurationSetup.cs is responsible for initializing application configuration sources. Bob’s Used Books relies on Parameter Store, a capability of AWS Systems Manager to store application configuration. We used the AWS .NET Configuration Extension for Systems Manager to connect the application to Parameter Store. The code looks like this:

builder.Configuration.AddSystemsManager("/BobsBookstore/", optional: true);

The optional: true parameter tells the configuration pipeline to ignore loading this configuration if the /BobsBookstore/ key isn’t found. This is useful in Local Debugging where we are not connecting through to Parameter Store.

Note: One thing to be aware of with the optional flag is that it suppresses errors when retrieving parameters from Parameter Store. If you are using Integrated Debugging or Full Deployment and parameters are not being retrieved remove the optional parameter to see the exceptions.

DependencyInjectionSetup.cs

DependencyInjectionSetup.cs is responsible for wiring up service and repository implementations. It also wires up the following services:

FileService – FileService is used to store book images. Bob’s Used Books uses Amazon Simple Storage Service (Amazon S3) for image storage, however the application swaps this out for a file system implementation in Local Debugging.

ImageValidationService – The ImageValidationService performs content moderation on images that are uploaded to Bob’s Used Books. The application uses Amazon Rekognition to perform content moderation, however in Local Debugging it is swapped out for a mock implementation that approves all image uploads.

MiddlewareSetup.cs

MiddlewareSetup.cs is responsible for configuring the middleware pipeline for the application and is typical of most ASP.NET Core MVC applications.

ServicesSetup.cs

ServicesSetup.cs is responsible for wiring up the AWS SDK for .NET clients the application uses to interact with AWS services like Amazon S3 and Amazon Rekognition. It is also used to wire up the database. Bob’s Used Books uses an Amazon Relational Database Service (Amazon RDS) for SQL Server database for the application backend, however it is swapped out for a SQL Server LocalDb instance in Local Debugging and Integrated Debugging.

Bookstore.Domain Project

The Bookstore.Domain project contains the Application layer and the Domain layer. We considered splitting these out into separate projects (i.e. Bookstore.Application and Bookstore.Domain) however that would have required more boilerplate code to map between objects as requests and responses traveled across project boundaries and in the end we favored a simpler approach. The tradeoff though of keeping both layers in the same project is that it is easy to abuse the architecture and do things like include domain objects directly in ASP.NET Core ViewModels.

Application services are defined and implemented within the Application layer. Application services are responsible for calling into and coordinating across the Domain layer to exercise the business logic of the application. For example, when creating a new Order, OrderService will perform customer and address lookups, transfer books from shopping carts to new orders, and update inventory levels. Application clients (for example Bookstore.Web) always interact with the application and exercise business logic through the application services.

Bob’s Used Books takes advantage of the Repository Pattern; a concept that was popularized by Domain Driven Design. A repository is a collection of domain objects. Repository interfaces are defined within the Domain layer and are considered domain objects, however their implementation, which is often tied to an underlying data store such as a database, is offloaded to a different layer (in this case it is the Infrastructure layer within the Bookstore.Data project). This helps keep the domain cohesive and loosely coupled. If we wanted to modernize Bob’s Used Books’ SQL Server database to an open source alternative like Amazon Aurora, we only need to update the implementation code in the Bookstore.Data project and shouldn’t need to touch the rest of the application.

Bookstore.Data Project

The Bookstore.Data project is responsible for low-level implementation concerns. It implements the repository interfaces that are defined in the Domain layer of the Bookstore.Domain project. We considered a few different repository implementations including a Generic Repository implementation, however we ended up deciding that “simple is best” and have, for the most part, defined a repository per entity that simply wraps DbContext and exposes standard querying capabilities.

Bob’s Used Books uses Entity Framework (EF) Core as the Object-Relational Mapper (ORM) to communicate with the underlying database. We have taken a model-first approach and rely heavily on EF conventions to drive the creation of, and interaction with, the database. The database is seeded with sample data during creation. This works well for a sample application, but may not be appropriate for all applications.

Application Conventions

Bob’s Used Books relies on a few conventions to ensure the application is simple to understand and well-architected without being over engineered. These conventions are particularly important given the decision to implement both the Application layer and the Domain layer within the Bookstore.Domain project.

Application Services

The Presentation layer communicates with lower layers of the application via application services that are defined in the Application layer of the Bookstore.Domain project. For example, if the Presentation layer retrieves a list of customer addresses it uses the IAddressService Application service, i.e.:

var addresses = await addressService.GetAddressesAsync(User.GetSub());

The Presentation layer never uses repositories directly. In order to enforce this convention repositories have been implemented in a way that prevents them from being used directly in the Bookstore.Web project.

There are pros and cons to enforcing this convention. It is easy for developers new to the project to quickly understand how communication flows through the application: everything goes through an application service. However, for a simple application like Bob’s Used Books we end up with some application service methods that are rather anemic. We felt this was a worthy tradeoff because it provides a consistent implementation that is easy to understand.

ViewModels

The Presentation layer always uses strongly-typed ViewModels. The ViewModels are responsible for mapping data from domain objects. For example, the CheckoutIndexViewModel, which is used to populate the Checkout view, requires data about a customer’s shopping cart and also a list of their addresses. The customer’s shopping cart is retrieved via the ShoppingCartService and the customer’s addresses are retrieved via the AddressService:

var shoppingCart = await shoppingCartService.GetShoppingCartAsync(HttpContext.GetShoppingCartCorrelationId());

var addresses = await addressService.GetAddressesAsync(User.GetSub());

The shopping cart and address list is then used to construct a new CheckoutIndexViewModel and return the view:

return View(new CheckoutIndexViewModel(shoppingCart, addresses));

We considered a few different ways to manage mapping between objects including third-party mapping libraries, dedicated mapping classes, and extension methods, however in the end we favored a simple approach. Because we combined the Domain layer and the Application layer within the Bookstore.Domain project we have direct access to domain objects from within the Presentation layer. This enabled us to make the ViewModels responsible for mapping the domain objects and to simplify the overall solution. The downside to this approach is there is nothing, other than convention, stopping a developer from using domain objects rather than ViewModels when creating a new view.

Application Service Method Parameter Types

Application service methods accept parameters in the form of Data Transfer Objects (DTOs) that are defined in the Application layer. For example, when creating a new Order the Presentation layer creates a new CreateOrderDto object and passes it to IOrderService.CreateOrderAsync(CreateOrderDto dto), i.e.:

var dto = new CreateOrderDto(User.GetSub(), HttpContext.GetShoppingCartCorrelationId(), model.SelectedAddressId);

var orderId = await orderService.CreateOrderAsync(dto);

Application Service Return Types

Application services return domain objects directly. For example, when the Presentation layer requests a list of customer addresses, IAddressService returns a List<Address>. The Presentation layer is then responsible for mapping those domain objects to ViewModels. We considered returning DTOs from application services however this introduced additional mapping that we felt was distracting in this sample. For larger, more complex, and or distributed applications, returning DTOs from application services may be a better approach.

Conclusion

Bob’s Used Books follows the principles of Clean Architecture to deliver a real-world example of building .NET applications on AWS. When faced with design choices we have typically taken the simplest and most pragmatic approach. We rely on a number of conventions to ensure the application architecture and code is consistent, easy to understand, and practical.

You can download Bob’s Used Books from our GitHub repository. Take a look at my first post in this series to learn how to get up and running with the application and the different debug and deployment modes that are available.