In this article, I will take you through a simple yet powerful way to build web applications — We’ll be exploring an example vertical slice architecture in ASP.NET core. This approach is different from traditional ways of making web apps. Instead of creating separate layers for things like data and logic, we build the app in ‘slices,’ where each slice has everything it needs from start to finish for a specific feature.
We’ll use a travel and booking app as an example. This will help you see how vertical slice architecture works in a real project. You’ll learn how to set up your app, organize your code, and make each part of your app work well on its own. This method makes your app easier to manage and update.
By the end of this article, you’ll understand how to use vertical slice architecture in your ASP.NET Core projects, making your work simpler and more efficient. So, let’s get started on this easy-to-follow guide to building better web apps!
Table of Contents
- Introduction to Vertical Slice Architecture
- Setting Up the ASP.NET Core Project
- Implementing Vertical Slice Architecture
- Feature Implementation and Testing
- Domain Models and Database Context
- Dependency Injection in Vertical Slices with Minimal APIs
- Piecing the Code Together in a Vertical Slice Architecture
- We Can Do Better For Vertical Slice Architecture…
- Hands-On With Testing Our Vertical Slice
- Concluding Our Example Vertical Slice Architecture in ASP.NET Core
Introduction to Vertical Slice Architecture
In web application development, choosing the right architecture is crucial. Vertical Slice Architecture is an approach that’s different from the traditional layered architecture. Instead of organizing an app into separate layers for things like data and user interface, this method cuts the app into vertical slices. Each slice covers everything needed for one feature, from the front end to the database.
This approach means that every feature in your app is like a mini-app that’s independent of the others. It’s great for making changes or adding new features because you only have to work on one slice at a time, not the whole app. This makes your app easier to manage and update.
In traditional layered architecture, you might have separate layers for showing data to users, handling business logic, and managing the database. But making changes in one layer can affect the others, which can make things complicated. With Vertical Slice Architecture, changes in one slice usually don’t affect the others. This keeps things simpler and more flexible, especially when you need to update or fix parts of your app. It’s a straightforward and effective way to build and manage web applications.
Setting Up the ASP.NET Core Project
Initial Setup For Working with ASP.NET Core
You’ll want to ensure you have Visual Studio or VS Code installed. For the sake of this article, I’ll be focused on working in Visual Studio but everything I’m covering will be applicable to both IDEs — so no sweat. Next, we’ll look at the project setup.
Setting up your ASP.NET Core project for vertical slice architecture begins with a few key steps to lay the groundwork for a well-organized application. Here’s how to start:
- Create a New Project: Use Visual Studio or the .NET CLI to create a new ASP.NET Core project. Choose a Web API or MVC template based on your preference. For our example, let’s assume a Web API project.
- Configure Necessary Packages: Ensure you have all necessary NuGet packages installed. This typically includes packages for Entity Framework Core if you’re planning to use a database.
- Setup Dependency Injection: ASP.NET Core comes with built-in support for dependency injection. Configure your services in the
Startup.cs
file or inProgram.cs
if you’re using .NET 5 or later. This will be crucial for managing dependencies within each vertical slice. - Database Configuration: If your application will use a database, set up the connection string and configure the database context. This can be done in the
appsettings.json
file and theStartup.cs
orProgram.cs
file.
Watch this video if you want to see a project get created in less than 5 minutes:
General Project Structure
The organization of folders and files in vertical slice architecture is different from traditional layered architecture. Instead of separating by type (like Controllers, Models, Views), you separate by feature. Each feature, or ‘slice’, will have its own folder containing all necessary classes and files. Here’s how to structure it:
- Feature Folders: Create a folder for each feature or vertical slice. For example, a folder named
Authentication
for user authentication and registration. - Inside Feature Folders: Each folder contains everything related to that feature – controllers, services, models, and any other classes. For example, in the
Authentication
folder, you might haveAuthenticationService.cs
,UserController.cs
, andUserModel.cs
. - Shared Resources: If you have resources shared across different slices, like common utilities or middleware, place them in a separate folder like
Shared
orCommon
. - Test Folders: Reflect this structure in your test project as well. Each feature should have its corresponding test folder containing unit and integration tests for that feature.
By setting up your project in this manner, you ensure that each feature is encapsulated within its slice. This makes the application easier to understand, maintain, and extend. Changes to one feature have minimal impact on others, and new team members can quickly grasp the structure and contribute to the project.
Implementing Vertical Slice Architecture
Defining the Vertical Slices
In a travel and booking application, implementing vertical slice architecture starts by identifying and defining the vertical slices. A vertical slice is essentially a cross-section of the application that delivers a complete feature from front to back. Here’s how to identify and define these slices:
- Identify Features: Start by listing out the core features of your travel and booking application. For example, user authentication, flight booking, hotel reservations, and user reviews.
- Break Down Features: Each feature should be broken down into its smallest functional unit. For instance, the flight booking feature can be divided into searching flights, booking a flight, and managing bookings.
- Define Scope of Each Slice: Clearly define what each slice will cover. The flight booking slice, for example, should handle everything from displaying flight options to processing the booking.
- Consider User Journeys: Think about how users will interact with each feature. This helps in ensuring that each slice fully addresses a particular user need or journey.
Isolation and Modularity
Maintaining isolation and modularity within each vertical slice is crucial. This means each slice should be as independent as possible, both in terms of code and functionality. Here’s why this is important:
- Independent Development and Testing: Slices should be able to be developed and tested independently from other vertical slices. This reduces dependencies and potential conflicts with other parts of the application.
- Focused Codebases: Keeping code related to a specific feature within its slice makes the application easier to understand and maintain. Developers working on a slice have everything they need within that context, without having to sift through unrelated code.
- Scalability: As your application grows, it’s easier to add or modify features when they’re self-contained. Each slice can evolve independently, which is more efficient and less risky than making changes in a tightly integrated codebase.
- Flexibility in Tech Choices: Different slices can potentially use different technologies or approaches as needed, without impacting the rest of the application.
- Reusability: Components within a slice can be designed for reusability within that slice, reducing redundancy and improving consistency.
In a travel and booking application, ensuring that each feature like flight search, hotel booking, or user management operates independently as a vertical slice allows for more agile development and easier maintenance. It also sets a clear structure for expanding the application, as new features can be added as new slices following the same principles.
Feature Implementation and Testing
Developing a Core Feature: User Authentication and Registration
Implementing a core feature in vertical slice architecture involves a focused approach where each aspect of the feature is developed as a part of a self-contained unit. Let’s consider the implementation of ‘User Authentication and Registration’ as an example.
- Feature Identification: Start by clearly defining what the ‘User Authentication and Registration’ feature entails – user registration, login functionality, and user authentication.
- Service Layer: Develop a service layer (
UserService
) that contains the business logic for user registration and authentication. This service will handle tasks such as password hashing, validation of user inputs, and interaction with the database for user data. - API Endpoints: Create API endpoints using minimal APIs in ASP.NET Core. These endpoints in
Program.cs
or a separate configuration file handle HTTP requests for user registration and login, calling the respective methods in theUserService
. - Data Models: Define data models and DTOs (Data Transfer Objects) necessary for this feature, like
UserDto
andUserLoginDto
, to encapsulate the data coming in from API requests. - Database Integration: Connect the feature with the database layer, ensuring that user information is correctly stored and retrieved. This involves setting up Entity Framework contexts and corresponding database operations within the feature slice.
We’ll be visiting some code in the upcoming sections. Hang tight!
Testing Coverage for Our Vertical Slices
Testing is a critical component of each vertical slice, ensuring that each feature not only works in isolation but also interacts correctly with other parts of the system.
- Unit Testing: Write unit tests for the
UserService
, focusing on testing the business logic in isolation. Mock external dependencies like database contexts to ensure tests are not affected by external factors. - Integration Testing: Perform integration tests on the API endpoints. These tests involve making actual HTTP calls to the endpoints and asserting the responses. They verify that the integration between the API layer, the service layer, and the database is functioning as expected. I personally love using testcontainers for this.
- Test Automation: Integrate these tests into your build process. Ensure that every time changes are made, tests are automatically run to catch any issues early.
- Continuous Testing: Adopt a mindset of continuous testing, where tests are updated and expanded as the feature develops. This ensures that the feature remains robust and reliable as changes are made.
By focusing on one feature slice at a time, like ‘User Authentication and Registration’, and integrating testing as part of the development process, you ensure that each part of your application is reliable and functions well within the larger system. This approach not only streamlines development but also maintains high standards of quality and performance.
Domain Models and Database Context
Creating Contextual Models
In vertical slice architecture, domain models are created specific to each slice, ensuring that they are closely aligned with the slice’s functionality. This approach leads to more maintainable and relevant models within each context.
- Identify Model Requirements: For each vertical slice, identify what data is required. In the ‘User Authentication and Registration’ slice, for instance, you might need a
User
model with properties likeUsername
,Email
, andPasswordHash
. - Design Models for the Slice: Design your models to encapsulate only the data needed for the specific slice. Avoid the temptation to create large, all-encompassing models that cater to multiple slices.
- Encapsulation and Separation: Keep domain models encapsulated within their slice. This isolation prevents unnecessary coupling between different parts of the application.
- Validation and Business Logic: Embed validation and business rules relevant to the slice within the model where possible. This ensures that data integrity and business rules are enforced at the model level.
Setup Data Access Per Slice
In vertical slice architecture, each slice can potentially have its own database context. I want to highlight here that if a lot of this sounds like microservices, and we know that “microservices need to have their own isolated data”… Vertical slices aren’t microservices. While I’d recommend, if you can, to isolate data to the domains you’re working in, the entire system doesn’t fall apart if you don’t do this.
Let’s think about this logically: The entire point of vertical slices is that we’re (ideally) better enabled to land entire features end to end without disrupting or being disrupted by other feature development. If your data for your vertical slice is changing underneath you because another vertical slice needs to have it stored a certain way, this can certainly cause unwanted disruption.
However, if you find that based on your data architecture it makes the most sense for the data storage to be shared — then do it. The data access within the slices could be responsible to transform the data as needed specifically for the slice. It’s totally an option. Just consider where the “coupling” occurs and weigh that against the benefits you’re hoping to get out of that.
Working With Entity Framework
This section covers how to set up Entity Framework contexts for each slice and when to consider shared versus separate databases.
- Slice-Specific Contexts: Define an Entity Framework context for each slice that requires database interaction. In the ‘User Authentication and Registration’ slice, you would have a
UserDbContext
handling user-related data operations. - Configuration: Configure the context within the slice, including connection strings and database settings. This can be done in the
Startup.cs
orProgram.cs
file, or within the slice’s configuration if using a more modular approach. - Migrations: Manage database migrations within each slice. This allows each slice to evolve its database schema independently, reducing the complexity of database changes and minimizing the impact on other slices.
- Shared vs. Separate Databases:
- Shared Database: If slices are closely related and share a lot of data, a shared database might be more efficient. This requires careful management to avoid tight coupling between slices.
- Separate Databases: For slices that are highly independent or could evolve separately, consider using separate databases. This enhances the modularity and scalability of the application but adds complexity in terms of data management and cross-slice operations.
By focusing on creating contextual models and setting up database contexts tailored to the needs of each vertical slice, you can build an ASP.NET Core application that is modular, maintainable, and scalable. This approach ensures that each part of your application has exactly what it needs to function effectively, without unnecessary dependencies on other parts of the system.
Dependency Injection in Vertical Slices with Minimal APIs
Configuring Services
In a minimal API setup within ASP.NET Core, dependency injection plays a crucial role in managing services across vertical slices. Here’s how to configure it effectively:
- Define Services for Each Slice: Identify and create the services required for each vertical slice. For instance, in a ‘User Authentication’ slice, you might have a
UserService
for handling user-related operations. - Service Registration: In your
Program.cs
, register these services with the built-in dependency injection container provided by ASP.NET Core. Choose the appropriate service lifetime (scoped, transient, or singleton) based on the use case.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<IUserService, UserService>();
// Other service registrations
- Injecting Services in Endpoints: With minimal APIs, you can inject services directly into your endpoint methods. This keeps your slices independent and makes their dependencies explicit.
app.MapPost("/register", async (UserDto userDto, IUserService userService) =>
{
// Use userService to handle registration
});
Maintaining Independence
Keeping services modular and independent within each slice is crucial in a vertical slice architecture, especially with minimal APIs.
- Isolate Logic in Services: Ensure that the logic in each service is specific to the slice’s needs. Avoid interdependencies between services of different slices.
- Interface-Based Design: Define services using interfaces to ensure loose coupling. This approach also makes it easier to mock these services for testing.
- Common Services: For functionalities that are common across slices, like logging or database context, create shared services. Inject these shared services where needed, while being cautious about maintaining slice boundaries.
- Prevent Service Leakage: Be mindful not to let services from one slice directly depend on another slice’s services. If interaction is necessary, use well-defined interfaces or patterns like messaging to keep slices decoupled.
- Mocking in Tests: Use mocks for dependencies when testing services. This ensures that your tests are focused on the slice’s functionality and not on external services.
Through careful configuration of services and maintaining strict boundaries, dependency injection in a minimal API setup enables each vertical slice to function independently, enhancing the maintainability and scalability of your ASP.NET Core application. This approach facilitates a clear structure for adding new features and simplifies the development process.
Piecing the Code Together in a Vertical Slice Architecture
To demonstrate how the various elements of a vertical slice come together in an ASP.NET Core application using minimal APIs, let’s focus on the ‘User Authentication and Registration’ slice. This example will show how domain models, database context, service layer, dependency injection, and endpoint definitions are integrated. We’ll continue with the same vertical slice that we’ve been discussing through the article!
1. The Domain Model
The User
class represents the data model in our slice, containing properties relevant to the user entity.
public class User
{
public int Id { get; set; }
public string Username { get; set; }
public string Email { get; set; }
public string PasswordHash { get; set; }
// Additional properties as needed
}
2. Data Transfer Objects (DTOs)
DTOs are used to transfer data between the client and the server. They help encapsulate and validate incoming data.
public class UserDto
{
public string Username { get; set; }
public string Email { get; set; }
public string Password { get; set; }
}
public class UserLoginDto
{
public string Email { get; set; }
public string Password { get; set; }
}
3. Service Layer of Our Vertical Slice
The UserService
handles business logic for user registration and authentication.
public class UserService : IUserService
{
private readonly DbContext _context; // Injected via DI
public UserService(DbContext context)
{
_context = context;
}
public async Task<bool> RegisterAsync(UserDto userDto)
{
// Implement registration logic
}
public async Task<User> LoginAsync(UserLoginDto userLoginDto)
{
// Implement login logic
}
}
4. Database Context – UserDbContext
The UserDbContext
is responsible for interacting with the database for this slice.
public class UserDbContext : DbContext
{
public UserDbContext(DbContextOptions<UserDbContext> options) : base(options) {}
public DbSet<User> Users { get; set; }
// Additional DbSet properties as needed
}
5. Dependency Injection
Services and database context are registered for dependency injection in Program.cs
.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddDbContext<UserDbContext>(options => /* Database options */);
// Additional DI configurations
6. Endpoint Definitions Using Minimal APIs
Endpoints for user registration and login are defined, injecting necessary services.
var app = builder.Build();
app.MapPost("/register", async (UserDto userDto, IUserService userService) =>
{
// Call userService.RegisterAsync
});
app.MapPost("/login", async (UserLoginDto userLoginDto, IUserService userService) =>
{
// Call userService.LoginAsync
});
app.Run();
We Can Do Better For Vertical Slice Architecture…
Did you feel like dropping all of that code in the program.cs file was weird? If we’re dealing with a vertical slice, why are we about to pile all of our vertical slice code into that spot? We can refactor this!
To refactor the dependency injection and endpoint definitions using extension methods, you can pull out the slice-specific logic from Program.cs
. This approach enhances modularity and maintains the separation of concerns. Here’s how you can implement it:
Define an Extension Method for Service Registration
public static class ServiceExtensions
{
public static void AddUserAuthenticationServices(this IServiceCollection services, string connectionString)
{
services.AddScoped<IUserService, UserService>();
services.AddDbContext<UserDbContext>(options => options.UseSqlServer(connectionString));
// Any other DI configurations specific to User Authentication
}
}
Define an Extension Method for Endpoint Definitions
public static class EndpointExtensions
{
public static void MapUserAuthenticationEndpoints(this WebApplication app)
{
app.MapPost("/register", async (UserDto userDto, IUserService userService) =>
{
// Call userService.RegisterAsync
});
app.MapPost("/login", async (UserLoginDto userLoginDto, IUserService userService) =>
{
// Call userService.LoginAsync
});
// Any other endpoint mappings specific to User Authentication
}
}
Refactor the Entry Point
Now, you can use these extension methods in Program.cs
to keep it clean and focused:
var builder = WebApplication.CreateBuilder(args);
// Use extension method to add user authentication services
builder.Services.AddUserAuthenticationServices(builder.Configuration.GetConnectionString("DefaultConnection"));
var app = builder.Build();
// Use extension method to map user authentication endpoints
app.MapUserAuthenticationEndpoints();
app.Run();
Why Might This Be Preferred?
By using these extension methods, Program.cs
now becomes more streamlined and easier to manage. The AddUserAuthenticationServices
method abstracts away the details of registering services and setting up the database context for the ‘User Authentication’ slice. Similarly, MapUserAuthenticationEndpoints
handles the routing for this specific feature. This separation not only improves the organization of the code but also makes it easier to maintain and scale, as each vertical slice can be modified independently without cluttering the main program file.
If you wanted to do EVEN BETTER than this, you could perhaps explore plugins within vertical slice architecture! You can check out this video for more context on how I’ve used plugins with my vertical slice architecture implementations:
Hands-On With Testing Our Vertical Slice
Delivering features wouldn’t be complete without discussing testing. Just like how a vertical slice allows you to focus specifically on the feature you’re adding, you should group your testing effort into this effort as well. Start and end strong!
To write a unit test for the LoginAsync
method in the UserService
using xUnit, we need to focus on testing the method’s behavior in isolation. This involves mocking dependencies like the database context (DbContext
) and any other external services the method might rely on. Here’s an example of how you might structure such a test:
Preparing the Test Environment
So far we’ve been discussing adding code in the core application. However, tests need to go into a different project. Here’s some quick pointers on getting set up:
- Create a Test Project: Set up a test project in your solution if you haven’t already. This project should reference xUnit, your main project, and a mocking framework like Moq.
- Add Necessary Dependencies: Include necessary using directives for xUnit, Moq, and any namespaces from your main project that you’ll be testing.
You can check out my other articles on working with Moq and xUnit as well!
Writing the Test
Here’s a step-by-step breakdown to write something that could help cover the LoginAsync
method:
- Mock Dependencies: Create mock instances of any dependencies
LoginAsync
requires. For example, if it interacts withDbContext
to fetch user data, you’ll need to mock this context.
MockRepository mockRepository = new(MockBehavior.Strict);
var mockContext = mockRepository.Create<DbContext>();
// Setup mock methods and properties as needed
- Setup Test Data: Prepare the data that
LoginAsync
will work with. For example, create a fake user that would be returned by the mockedDbContext
.
var fakeUser = new User { /* Initialize user properties */ };
// Setup mockContext to return this user when queried
- Instantiate the Service: Create an instance of
UserService
, injecting the mocked dependencies.
var userService = new UserService(mockContext.Object);
- Write the Test Method: Create a test method using xUnit to test the
LoginAsync
function. Use assertions to validate the expected behavior.
[Fact]
public async Task LoginAsync_ReturnsUser_WhenCredentialsAreValid()
{
// Arrange
var userLoginDto = new UserLoginDto { /* Set valid credentials */ };
// Act
var result = await userService.LoginAsync(userLoginDto);
// Assert
Assert.NotNull(result);
Assert.IsType<User>(result);
// Additional assertions as needed
}
- Negative Test Cases: Don’t forget to write tests for scenarios where login should fail, like incorrect credentials. These tests help ensure that your method handles errors and edge cases correctly. What things should you be considering when writing the negative test case?
[Fact]
public async Task LoginAsync_ReturnsNull_WhenCredentialsAreInvalid()
{
// Arrange for invalid scenario
// Act and Assert
}
Running the Test
Execute the test using your preferred method (Visual Studio Test Explorer, dotnet test command, etc.). Ensure the tests cover both successful and unsuccessful login attempts, and that they validate the key behaviors of the LoginAsync
method. Remember that for unit tests we want to make sure our dependencies are mocked. If you would prefer writing functional tests, you can structure your tests differently.
Not sure which tests are best to write? If the “experts” on the Internet have you confused because they’re telling you different test types are better, check out this video for more perspective:
Concluding Our Example Vertical Slice Architecture in ASP.NET Core
As we wrap up our example vertical slice architecture in ASP.NET Core, we’ve covered a lot of ground on making web apps in a new and efficient way. This approach helps us build apps by focusing on one feature at a time, insulated from other features, which makes it easier to manage and improve the app.
We started by setting up an ASP.NET Core project, and then we looked at how to make and use different parts of the app for each feature. For example, we saw how to make a ‘User Authentication and Registration’ feature, which included things like user services, data models, and simple API endpoints. We also talked about the importance of testing, which should be delivered with your vertical slice. From this point, you can continue on the same pattern to deliver future vertical slices.
This way of building apps in ASP.NET Core is really useful and something that I leverage frequently! If you’re interested in more learning opportunities, subscribe to my free weekly newsletter and check out my YouTube channel!
Very informative . I will explore more this architecture.
Glad that you enjoyed it!