Jumpstart Your C# Project Using a 3-Tier Architecture Approach
Written on
Chapter 1: Introduction to 3-Tier Architecture
In this guide, we will delve into the creation of a 3-tier application, demonstrating the functionality of this architecture and the essential considerations involved.
Previously, I explored the concept of 3-tier architecture, albeit in a rather tedious, text-heavy manner. This tutorial aims to provide a more engaging walkthrough on initiating a C# project with the 3-tier architecture. If you're unfamiliar with the concept, I recommend reviewing the earlier blog post for foundational knowledge.
Where Should You Begin?
A common inquiry I encountered while teaching was, "How do I start a new project?" Unfortunately, there's no one-size-fits-all answer. Project initiation can vary widely; some individuals dive straight into coding, allowing the architecture to develop organically, while others prefer to outline strategies and diagrams in advance.
Personally, I tend to establish the fundamental structure and architecture before I begin coding. However, the approach you take often depends on the scale of the project. Smaller projects are straightforward to initiate and complete, whereas larger projects typically require an architect to guide the development team.
In this tutorial, I will illustrate my usual process for starting small to moderately-sized projects, specifically by developing an API for a movie database, which will consist of various layers.
Consider Your Structure
While it's tempting to jump right into creating projects within your solution, it’s wise to pause and consider the necessary components. Generally, you will need:
- A presentation layer: This can be an API, a console application, or a web app.
- An application layer: Often referred to as the business layer, this contains all your project logic.
- A data layer: This encompasses all data-related tasks.
- An ORM: If a database is involved, you will likely need to set up a database environment.
Each layer functions as a class library with specific responsibilities, and each class within those libraries carries its own duties.
Additionally, naming conventions are crucial. It's advisable to thoughtfully name your projects and layers before creation, as renaming them later can lead to complications.
For quick experiments, you can bypass the creation of all these layers. A simple console application will suffice for prototyping—allowing you to extract useful code later for your main project.
Creating the Projects
To kick things off, launch Visual Studio and select an appropriate template. Since I aim to create an API, I will start with that template and build the layers as needed.
The Presentation Layer (API)
I won’t dive into API definitions or functionalities. Instead, I will focus on essential considerations for its creation.
Let's open Visual Studio and initiate a new project, choosing the ASP.NET Core WebAPI template. Naming is critical; for my movie database API, I will name it "MovieDatabase.API." This naming convention clearly indicates its purpose as an API and presentation layer.
The solution name should not merely mirror the project name. I will rename it to "MovieDatabase" to reflect that the solution will encompass multiple projects related to the movie database.
Next, I will select .NET 7, keep HTTPS enabled, uncheck "Use controllers if needed," activate OpenAPI, and ensure "do not use top-level statements" is unchecked.
The Application Layer (Business Logic)
The presentation layer should remain uncomplicated, focusing primarily on presenting data sourced from the application layer.
I prefer to refer to the application layer as the "business" layer, which retains the function of managing project logic.
Let’s add the business layer to our solution by creating a new class library project. Again, naming is vital. This class library will be for the movie database and will be named "MovieDatabase.Business." A consistent naming convention can be beneficial; consider prefixing project names with the solution name.
I will revisit this project later.
The Data Layer
I have mixed feelings about the necessity of a dedicated data layer. If you opt for Entity Framework, the required classes and lines can be minimal, making a separate layer seem excessive. Nonetheless, for the sake of this tutorial, I will include it.
To create the data layer, I will follow the same steps as I did for the business layer: by adding a new class library. I will label this layer "MovieDatabase.Data."
Now that all layers are established, we can proceed to coding.
Writing Some Code
Now, let's write some code while highlighting prevalent challenges you might face.
First, how do we enable communication between the layers? You could instantiate each class as needed, but using dependency injection is a more efficient approach. This method allows for the initialization of all required classes at once, making them accessible throughout the application.
A Simplified Service
To implement and configure dependency injection, we need interfaces. These interfaces will connect to the classes. I will begin by creating a simple interface and class in the business layer.
I will create a class named "MovieService" in "MovieDatabase.Business," which will manage movie-related responsibilities exclusively.
Next, I’ll create an interface, IMovieService, and place it in its own folder, "Interfaces," within "MovieDatabase.Business." I will define the following methods:
public interface IMovieService
{
Movie? Get(int id);
IEnumerable Get();
void Create(Movie movie);
void Delete(int id);
}
A Movie object is necessary, so I will add a class in the "Models" folder of "MovieDatabase.Business" to define the Movie class with these properties:
public class Movie
{
public int Id { get; set; }
public string Title { get; set; }
public DateTime ReleaseDate { get; set; }
public int Rating { get; set; }
}
Ensure you add the required references in IMovieService as needed!
Now we can link the interface to the MovieService class and implement it:
public class MovieService : IMovieService
{
public void Create(Movie movie)
{
throw new NotImplementedException();}
public void Delete(int id)
{
throw new NotImplementedException();}
public Movie Get(int id)
{
throw new NotImplementedException();}
public IEnumerable Get()
{
throw new NotImplementedException();}
}
Remember to add the appropriate references and usings. Now we can inject the IMovieService wherever necessary, such as in a mapping.
Let’s introduce a mapping in Program.cs:
app.MapGet("api/movies", (IMovieService movieService) =>
{
return Results.Ok(movieService.Get());
});
app.MapGet("/api/movies/{id:int}", (int id, IMovieService movieService) =>
{
return Results.Ok(movieService.Get(id));
});
app.MapDelete("api/movies/{id:int}", (int id, IMovieService movieService) =>
{
movieService.Delete(id);
return Results.NoContent();
});
app.MapPost("api/movies", (Movie movie, IMovieService movieService) =>
{
movieService.Create(movie);
return Results.NoContent();
});
Looks promising! Now we can run the API, but it will throw exceptions since none of the methods in the MovieService have been implemented. To resolve this, we need to focus on the data layer.
Entity Framework
Now, let’s replicate the steps taken for the business layer within the data layer. Create a folder named "Interfaces" in the data layer and another folder named "Entities," as I prefer using "Entities" to denote the objects utilized by Entity Framework.
To facilitate Entity Framework functionality within the data layer, we need a DataContext class and some packages. To maintain consistency, I will implement the repository pattern, which I will explain later.
First, add a Movie class to the "Entities" folder. This class will have the same properties as the one in the business layer:
public class Movie
{
public int Id { get; set; }
public string Title { get; set; }
public DateTime ReleaseDate { get; set; }
public int Rating { get; set; }
}
Next, add the DataContext class to the root of the data layer, inheriting from DbContext. Ensure the Microsoft.EntityFrameworkCore package is installed.
Then, use DbSet<T> to specify which entities are connected to a database table. We want to connect the Movie entity and add a constructor that configures DbContextOptions, including the connection string:
public class DataContext : DbContext
{
public DbSet Movies { get; set; }
public DataContext(DbContextOptions options) : base(options)
{ }
}
Now it’s time to establish the repository pattern, comprising an interface and its implementation. This interface, IRepository, will be employed in the business layer and will be generic:
public interface IRepository<T> where T : class
{
IQueryable GetAll();
void Delete(T entity);
void Create(T entity);
}
The implementation is as follows:
public class Repository<T> : IRepository<T> where T : class
{
private readonly DataContext context;
public Repository(DataContext context)
{
this.context = context;}
public void Create(T entity)
{
context.Set<T>().Add(entity);
context.SaveChanges();
}
public void Delete(T entity)
{
context.Remove(entity);
context.SaveChanges();
}
public IQueryable GetAll()
{
return context.Set<T>();}
}
Now we can utilize IRepository within the business layer. The business layer remains unaware of the implementation details since it only uses the interface.
Continuing the Business Logic
Returning to the MovieService in the business layer, we can now leverage the IRepository to facilitate communication with the data layer. Let’s update the MovieService class:
public class MovieService : IMovieService
{
private readonly IRepository<Movie> repository;
public MovieService(IRepository<Movie> repository)
{
this.repository = repository;}
public void Create(Movie movie)
{
repository.Create(movie);}
public void Delete(int id)
{
Movie? toDelete = Get(id) ?? throw new Exception("Movie not found.");
repository.Delete(toDelete);
}
public Movie? Get(int id)
{
return repository.GetAll().SingleOrDefault(x => x.Id == id);}
public IEnumerable Get()
{
return repository.GetAll().ToList();}
}
This is looking good! However, we must clarify which Movie object is being utilized here—the business model or the data entity? Hint: It’s the incorrect one.
Currently, the Movie model is in use, which is acceptable, but the repository expects an entity. Both the model and the entity share identical properties; to avoid circular references and potential errors, I typically create a distinct layer for shared models.
Introducing the Domain Layer
Occasionally, it's beneficial to share models and interfaces across multiple projects within your solution. This can be accomplished by creating a domain layer, which contains only models, interfaces, and enums without any logic.
I will add another class library to my solution, naming it "MovieDatabase.Domain." Within this domain project, I will establish "Models" and "Interfaces" folders. I will copy the Movie entity from the data layer into the "Models" folder of the domain and remove it from both "MovieDatabase.Data" and "MovieDatabase.Business." Ensure to adjust the references accordingly!
No interfaces will be moved to this project for now, as they aren't necessary.
Connecting Everything
We now have all the layers established and functioning... or do we? If you attempt to start the API and access an endpoint, you will encounter errors. We haven’t configured dependency injection or set up the DbContext, which are essential final steps.
Additionally, we need to create the database with migrations.
Application Setup
Let’s navigate to Program.cs and include all necessary dependency injections. Locate the line that reads builder.Services.AddAuthorization(); and add the following lines beneath it:
builder.Services
.AddDbContext<DataContext>(options =>
options.UseSqlServer(builder.Configuration["ConnectionStrings:Default"]),
ServiceLifetime.Scoped);
builder.Services.AddScoped<IRepository<Movie>, Repository<Movie>>();
For larger projects, this setup will expand over time. However, the AddDbContext and repository configurations will typically remain consistent.
Migrating to the Database
Next, we’ll set up the database. Begin by installing the Microsoft.EntityFrameworkCore.Tools package in the API project. Next, designate the API project as the startup project. Open the package manager console, set the default project to the data layer (MovieDatabase.Data), and execute the following command:
add-migration Initial
You may also want to install Microsoft.EntityFrameworkCore.Relational, as Visual Studio might overlook it.
Once the migration is successful, execute:
update-database
Now, you can run and test your API.
Conclusion
And there you have it: a fully functional, 3-tier solution. You can replace the API with a console app, WinForms, or a web application. Just keep in mind that the setup shown in Program.cs will differ for other application types.
In the end, your solution will comprise various projects, each with its own responsibilities, interconnected through dependency injection.
What's next? Some practices I've illustrated may seem unconventional or require further explanation—like the reasoning behind the repository pattern. You might also be curious about what a larger application looks like, complete with additional services and methods.
And what about testing? I've previously authored insightful tutorials on testing with C#.
That’s all for now! If you found this article helpful, please show your appreciation by clapping and hit the follow button to stay updated with my future writings.
Chapter 2: Additional Resources
In this video, titled "Tips for Teens: How to Jump Start a Car Battery," viewers will learn essential techniques for jump-starting a car, making it a useful resource for anyone looking to enhance their practical skills.