Featured resource
2026 Tech Forecast
2026 Tech Forecast

1,500+ tech insiders, business leaders, and Pluralsight Authors share their predictions on what’s shifting fastest and how to stay ahead.

Download the forecast
  • Lab
    • Libraries: If you want this lab, consider one of these libraries.
    • Core Tech
Labs

Guided: Refactoring an ASP.NET Core 10 App to Clean Architecture

A working application can be easy to build. A maintainable one - where the effort to add features stays low throughout the lifetime of the project - well, that takes more deliberate effort. In this code lab you will take a fully functional ASP.NET Core 10 Recipe Catalog application and refactor it from a single-project design - where controllers handle everything from validation to database queries to file operations - into a clean architecture with clearly separated layers. You will create a domain layer with entities that enforce their own business rules, an application layer that defines service contracts and orchestrates logic, and an infrastructure layer that implements data access and external services behind those contracts. By the end, the application will work exactly as before, but the code behind it will be structured for testability, flexibility, and growth.

Lab platform
Lab Info
Level
Advanced
Last updated
Apr 23, 2026
Duration
55m

Contact sales

By clicking submit, you agree to our Privacy Policy and Terms of Use, and consent to receive marketing emails from Pluralsight.
Table of Contents
  1. Challenge

    Introduction and Project Exploration

    Introduction to Clean Architecture

    There's a reason that many developers like working on new, greenfield projects. A new codebase is small, easy to navigate, and quick to change. But as features accumulate, the effort required to make changes grows steadily, and progress slows down. New functionality takes longer to add. Bug fixes in one area introduce regressions in another. Developers spend more time untangling code than writing it.

    This often happens when architectural boundaries are not clearly defined. Software should be soft or, in other words, easy to modify. The goal is to minimize the human effort required to build and maintain a system throughout its lifecycle.

    Clean architecture is one proven approach to achieving this. It organizes code into concentric layers, with business rules at the center and infrastructure concerns like databases, frameworks, and file systems at the edges. The golden rule is simple: dependencies point only inward. This means you can change how data is stored, how notifications are sent, or how the UI works without touching business logic.

    These architectural boundaries work hand-in-hand with good design at the class and method level. The SOLID principles guide how individual components are shaped within each layer.

    In this lab, you will apply these ideas with hands-on refactoring. You will take a working but poorly structured application and modify it into four clearly separated layers, each with well-defined boundaries. ### Lab Scenario

    You have been brought in to improve the architecture of a Recipe Catalog application built with ASP.NET Core 10.

    The application works. Users can browse, filter, create, edit, and delete recipes through a web interface, but all of the logic lives in a single project with fat controllers that directly access the database, write to the filesystem, and duplicate validation code.

    Your job is to refactor this application into Clean Architecture without changing its external behavior. The user interface will continue to work identically throughout.

    In the project folder Pluralsight.CleanArchitecture.Web, there is an ASP.NET MVC application that that you will restructure into separate domain, application, infrastructure, and presentation layers. ### Working with the Project

    Run the following commands in the terminal to navigate to the application folder and start the app:

    cd Pluralsight.CleanArchitecture.Web
    dotnet run
    

    Click on the Web Browser tab and open http://0.0.0.0:5246. You will see the web application running. Use the Open in new browser tab button to view in full-screen.

    Note: When making changes to the code, stop and re-run the app for the changes to take effect. Press CTRL+C in the terminal, then run dotnet run again. If you only need to verify that the code compiles, you can run dotnet build.

    Use the application to browse the recipe list, try the category and difficulty filters, create a new recipe, edit an existing one, and delete one. Everything works correctly, but internal structure can be improved. ### Exploring the Code

    Explore the files in the Pluralsight.CleanArchitecture.Web folder to understand how the application is currently structured.

    • In the Controllers folder, open RecipesController.cs. This single controller handles all CRUD operations for recipes. It handles multiple responsibilities, including HTTP request handling, Entity Framework Core data access, business validation, file writing for notifications, and mapping to view models. All in one class. This violates the Single Responsibility Principle (the "S" in SOLID). Compare also the Create (POST) and Edit (POST) actions and notice the duplicated validation logic.
    • In the Data folder, you will find RecipeCatalogDbContext.cs. This is the Entity Framework Core database context. Currently it lives directly in the Web project. There is tight coupling of the presentation layer to the data access technology.
    • In the Models folder, you will find Recipe.cs and Category.cs. These entity classes can be considered anemic. They are passive holders of data, with all properties freely settable and little behavior to protect business rules. Validation logic that should belong to the entity (such as enforcing a difficulty range of 1 to 5) lives in the controller instead.
    • In the ViewModels folder, there are view models used by the Razor views.
    • In the Views/Recipes folder, you will find the Razor views for the recipe list, create, edit, and delete pages.

    There are other files necessary for bootstrapping the web application and rendering the pages that you can review. None are essential to understand for the purposes of the lab nor will you be modifying them.

  2. Challenge

    Create the Domain Layer

    In Clean Architecture, the Domain layer sits at the center. It has no dependencies on any other project, framework, or library, and no direct interaction with databases, files, or network services. This is where your core business logic is held, with real-world business objects and relations modelled as entities.

    In the current codebase, Recipe and Category are simple classes with public getters and setters that any code can modify freely. There is nothing preventing a caller from setting a recipe's difficulty level to 99 or its preparation time to a negative number. These checks currently exist in the controller, but as the application grows and more parts of the system work with recipes, it becomes easy to miss applying or updating this validation.

    Centralizing this logic in one place avoids these issues.

    In this step, you will create a dedicated Domain project and build entity classes that take ownership of their own rules. The Recipe entity will use private setters and a dedicated method to ensure its properties can never be set to invalid values, no matter where in the application it is used. Unlike the existing Category model in the Web project, this class has no data annotation attributes such as [Required] or [StringLength].

    The domain entity is a plain C# class. Database constraints and validation rules will be handled in the Infrastructure layer and validation rules enforced by the entity's own methods. This gives you the same data shape as the existing Recipe model, but without any framework attributes. In the next task, you will change how two of these properties are set. This represents a shift from the previous implementation. Previously, this validation logic was duplicated across the Create and Edit actions in the controller.

    Now it lives in the entity and cannot be bypassed. The entity protects its own invariants, which reflects a core principle of domain-driven design.

    Any code that needs to set these values must go through UpdatePreparation, whether it is called from a service, a test, or another part of the application.

  3. Challenge

    Create the Application Layer

    The Application layer sits between the domain and the outside world. It defines what the application can do through service interfaces and their implementations, without knowing how databases or file systems work. It depends on the Domain project, but nothing else.

    This is where the Dependency Inversion Principle (the "D" in SOLID) comes in. Instead of the Application layer calling a database directly, it defines interfaces (contracts) that describe what it needs. Examples in our application are "give me all recipes" or "send a notification." The implementations of these contracts will live in the Infrastructure layer, which you will build in the next step.

    This separation makes Clean Architecture flexible. If you need to swap database technology or replace file-based notifications with an email service, you change only the Infrastructure layer. The Application layer, and its business logic, remains unchanged. The project reference to Domain allows the Application layer to use the Recipe and Category entities.

    Note: Application depends on Domain, not the other way around. The Domain layer remains completely independent. Repository contracts define how the Application layer interacts with data stores, without specifying the underlying. These interface use domain entities as their data types. The Application layer operates in terms of the domain.

    There are two focused interfaces rather than one large one. Each consumer depends only on what it needs. This design demonstrates the Interface Segregation Principle (the "I" in SOLID). Infrastructure is not limited to databases. The current application writes notifications to a file when a recipe is created. File-based notifications are also an infrastructure concern and should be abstracted. This is a deliberately simple interface. The Application layer only needs to know that it can send a notification, not how the notification is delivered. It could be written to a file, sent as an email, or posted to a message queue. That decision belongs to the Infrastructure layer. The Application layer is not limited to entities and interfaces. Application services orchestrate business logic by coordinating between repositories, domain entities, and other services to carry out use cases. Compare this to the before state. The Create and Edit actions in the controller both contained the same validation checks and data access logic.

    Now the service handles it once, and both the CreateAsync and UpdateAsync methods use recipe.UpdatePreparation() to enforce the business rules. This removes the duplication. The Application layer needs a way to register its services with the dependency injection container.

    Rather than registering these services in the Web project's Program.cs, each layer provides its own registration method. This prevents the Web project from depending on the internal classes of each layer.

  4. Challenge

    Create the Infrastructure Layer

    The Infrastructure layer is where your application meets the outside world. Databases, file systems, email services, message queues — anything that involves I/O lives here. Dependencies can freely be taken on the specific libraries required to implement the functionality of the application.

    Crucially, this layer depends on the Application layer, not the other way around. It implements the contracts defined by the Application layer.

    In this step you will create the Infrastructure project, move the database context out of the Web project, create repository implementations, implement the notification service, and configure the infrastructure components. Notice the dependency direction: Infrastructure references Application (and transitively Domain). It can see the contracts it needs to implement and the entities it needs to persist. But Application has no idea Infrastructure exists; it only knows about the interfaces. The RecipeCatalogDbContext currently lives in the Web project at Data/RecipeCatalogDbContext.cs.

    It needs to move to the Infrastructure layer. This is a direct consequence of the private setters you added in Step 2. Even seed data must go through UpdatePreparation() to set the difficulty and prep time. The domain rules are enforced consistently across the application. The Fluent API configuration (IsRequired, HasMaxLength) replaces the data annotation attributes from the previous model classes. The data access logic is the same as before, but now it lives in dedicated, focused classes behind clean interfaces rather than being scattered across controller actions. Again you need to register the components defined in the project with the dependency injection container. With this registration class in place, the Web project can wire up the entire Infrastructure layer with a single method call, just like the Application layer.

    The Web project's Program.cs does not need to reference RecipeRepository, CategoryRepository, or FileNotificationService directly.

  5. Challenge

    Refactor the Web Layer

    With the Domain, Application, and Infrastructure layers in place, it is time to bring everything together.

    The Web project is still the entry-point of the application, but it now becomes a much thinner shell. It handles HTTP requests, populates view models, and delegates everything else to the Application services. Anything that could be shared with another presentation, such as a public website or an API, is moved into an appropriate layer and can be reused.

    The views and view models remain unchanged, so the user experience remains unchanged. The Web project references both Application and Infrastructure. This is because Program.cs is the composition root, the one place where all the concrete types are wired together.

    At runtime, the Web project needs access to Infrastructure to register its services, but controllers depend only on the Application layer's interfaces. This follows the composition root pattern. All the concrete wiring happens in Program.cs. Controllers receive their dependencies through constructor injection without knowing the concrete implementations. The existing recipes controller is what is often called a God class, a single class that knows and does too much. In the next task, you will refactor this into a thin controller that delegates to Application services. The controller is now a humble object. It translates between HTTP and the Application layer, nothing more. All business logic, validation, data access, and notification logic have moved to the layers where they belong, and can more easily be tested. The controller is so thin and requires minimal testing. Now that the Web project uses the Domain entities via the Application and Infrastructure layers, the previous model and data access classes are no longer needed. If your solution does not compile, run dotnet build and review the errors in the terminal.

    Use the terminal errors to resolve any problems. The error messages will give you guidance as to which file and line number has a compilation issue.

    If the fix isn't obvious, look back over the step and task that relates to the file with an error. You may see that you have missed installing a NuGet package install, running a dotnet command, adding a using statement or have made a syntax error in the code.

    After fixing the errors, run dotnet build again. If the build succeeds, the refactoring to Clean Architecture is complete. You have gone from a single project to four, with dependencies correctly pointing inward.

  6. Challenge

    Verify and Sum Up

    Verification

    Run the application:

    cd Pluralsight.CleanArchitecture.Web
    dotnet run
    

    Open the web browser and verify each of the following:

    1. The recipe list displays the seeded recipes.
    2. Filtering by category shows only recipes in that category.
    3. Filtering by difficulty shows only recipes at that level.
    4. Creating a new recipe works. After creating one, verify that the file notifications/recipe-notifications.txt is created in the project root and contains a timestamped entry.
    5. Editing an existing recipe works.
    6. Deleting a recipe works.
    7. Create a recipe with a difficulty of 1 and a prep time over 60 minutes. The application should reject it with a validation error.

    The application behaves the same as it did in Step 1. The difference is entirely in the underlying architecture. ### What you have built

    You started with a single project where a single controller handled everything: HTTP requests, database queries, file I/O, business validation, and view model mapping. You now have four projects constructed according to clean architecture principles, each with a clear responsibility:

    • Domain holds the business rules. Entities protect their own invariants and have no dependencies on any framework.
    • Application defines what the application can do through service interfaces and orchestrates business logic. It depends only on Domain.
    • Infrastructure implements the Application layer's contracts using concrete technologies such as EF Core for data access and the filesystem for notifications. It can be swapped without touching business logic.
    • Web is a thin shell that translates between HTTP and the Application services. The controllers are humble objects with minimal logic.

    Every dependency points inward. The inner layers have no knowledge of the outer layers and each layer can evolve independently. ### Where to go from here

    This lab covered the foundations of Clean Architecture on what is, of course, a very simple application. There is more you can build on top of this structure as an application grows in complexity.

    Testability. One of the most significant benefits of what you have built is how much easier the code is to test. Consider what it would take to write automated tests for the original RecipesController. You would need a database, a real filesystem for notifications, and an HTTP context. To test RecipeService, you can mock IRecipeRepository, ICategoryRepository, and INotificationService, and test the business logic in isolation. The Domain is even simpler. Recipe.UpdatePreparation() can be tested with a plain unit test that verifies invalid values throw exceptions and valid values are assigned. Clean Architecture makes testing easy by design.

    Richer domain modeling. The UpdatePreparation method was a first step toward a richer domain model. In more complex systems, Domain-Driven Design (DDD) goes further with concepts like value objects (for example, a DifficultyLevel type that can never hold an invalid value), aggregates (clusters of entities that enforce consistency boundaries), and domain events (for example, a RecipeCreatedEvent that triggers the notification instead of the service calling it directly). These techniques keep business logic expressive and centralized as complexity grows.

    The Mediator pattern and CQRS. Instead of controllers calling service classes directly, you can use a library like MediatR to dispatch requests to handlers. Each handler is a self-contained class for a single operation, such as CreateRecipeCommandHandler. This supports Command Query Responsibility Segregation (CQRS), where reads and writes are modeled separately. It also reinforces the Open/Closed Principle (the "O" in SOLID), allowing new features to be added without modifying existing code.

    Validation frameworks. The validation in RecipeService is hand-written. Libraries like FluentValidation let you define validation rules in dedicated classes that are automatically applied before service logic runs, keeping validation concerns separate and reusable.

    Cross-cutting concerns. As the application grows, you will want consistent behavior across all operations, such as global error handling that converts exceptions to appropriate HTTP responses, structured logging with a library like Serilog, and pipeline behaviors for concerns like caching or performance monitoring. Clean Architecture gives these concerns natural places to live without polluting business logic.

    Swapping infrastructure. To swap the in-memory database for SQL Server or PostgreSQL, you only need to change the Infrastructure project by updating the EF Core provider and connection string. The Application and Domain layers remain unchanged. This is the core benefit of Clean Architecture: the database, web framework, and notification mechanism are all details that can evolve independently of the business rules they serve.

About the author

Andy is a developer and architect, working at a digital agency with a primary technical focus on using .Net and Azure to deliver solutions for clients. Future interests include, .Net, Azure, Azure Functions, Bot Framework, Alexa, Umbraco, Sitecore, and EPiServer.

Real skill practice before real-world application

Hands-on Labs are real environments created by industry experts to help you learn. These environments help you gain knowledge and experience, practice without compromising your system, test without risk, destroy without fear, and let you learn from your mistakes. Hands-on Labs: practice your skills before delivering in the real world.

Learn by doing

Engage hands-on with the tools and technologies you’re learning. You pick the skill, we provide the credentials and environment.

Follow your guide

All labs have detailed instructions and objectives, guiding you through the learning process and ensuring you understand every step.

Turn time into mastery

On average, you retain 75% more of your learning if you take time to practice. Hands-on labs set you up for success to make those skills stick.

Get started with Pluralsight