Clean Architecture: Dapper Repositories & Route Management
Hey everyone! Today, we're diving deep into building a robust and maintainable application using Clean Architecture, the power of Dapper for data access, and a practical example: Route Management. This article will guide you through setting up your project, structuring your folders, implementing repositories with Dapper, and building your first feature. Let's get started!
Introduction to Clean Architecture
Clean Architecture is a software design philosophy that prioritizes separation of concerns. The goal is to create systems that are independent of frameworks, databases, and UI. This makes your application highly testable, maintainable, and adaptable to change. The core idea revolves around layers, with the most abstract logic at the center and the concrete implementations on the outer layers. Let's break down the layers we'll be using:
Core
In the Core layer, you'll find your business logic, entities, and interfaces. This layer is the heart of your application and should be completely independent of any external dependencies. It defines what the application does, not how it does it. Think of it as the central nervous system, dictating the actions and rules of your application without knowing the specifics of the outside world. Entities are the business objects, like Route
, Station
, and Trip
in our case. Interfaces define contracts for services and repositories, outlining the operations without specifying the implementation. This separation allows us to swap out implementations (like changing databases) without affecting the core logic.
Application
The Application layer contains use cases and application logic. It orchestrates the business rules defined in the Core layer. This layer is responsible for handling requests, validating data, and coordinating the execution of business logic. It knows what to do with the entities and interfaces defined in the Core, but it doesn't know how the data is persisted or how external services are called. This layer is like the conductor of an orchestra, coordinating the different instruments (Core components) to produce a harmonious melody (application functionality). Think of it as the brain of your application, processing information and directing actions based on the rules and knowledge defined in the Core.
Infrastructure
The Infrastructure layer is where the concrete implementations live. This includes database access, external API integrations, and other technical details. This layer knows how things are done. If we're using Dapper to interact with a database, the Dapper-specific code resides here. It implements the interfaces defined in the Core, providing the actual mechanisms for data persistence, communication with external systems, and other technical tasks. This layer acts as the bridge between the abstract Core and the concrete world of databases, APIs, and other external services. It's like the limbs of the application, carrying out the actions dictated by the brain (Application layer) using the tools and resources available in the environment.
WebAPI
Finally, the WebAPI layer is the entry point to our application. It handles incoming requests, routes them to the appropriate application services, and returns responses. This layer is the face of the application, the part that interacts with the outside world. It doesn't contain any business logic itself; it simply acts as a gateway, translating external requests into internal actions and presenting the results to the user. This layer is like the voice of the application, communicating with users and other systems through APIs and interfaces.
Setting Up Your Project
Let's start by creating a new ASP.NET Core Web API project in Visual Studio or your preferred IDE. Name it something relevant, like RouteManagementAPI
. Once the project is created, we'll set up the folder structure to align with Clean Architecture:
RouteManagementAPI/
βββ Core/
β βββ Entities/
β βββ Interfaces/
βββ Application/
βββ Infrastructure/
βββ WebAPI/
βββ ...
This structure gives us a clear separation of concerns from the get-go. Inside each folder, we'll organize our files logically. For example, entity definitions will go in Core/Entities
, and interface definitions will go in Core/Interfaces
.
Implementing Dapper-Based Repositories
Dapper is a lightweight ORM (Object-Relational Mapper) that provides excellent performance. It's a great choice when you need fine-grained control over your SQL queries while still mapping the results to objects. Let's see how to implement repositories using Dapper.
Installing Dapper
First, install the Dapper NuGet package in your Infrastructure
project. You can do this via the NuGet Package Manager in Visual Studio or using the .NET CLI:
dotnet add package Dapper
Creating the Base Repository
To avoid code duplication, we'll create a base repository class that provides common database operations. This base repository will handle the connection management and basic CRUD operations. This base class ensures that common functionalities are handled consistently across all repositories. It abstracts away the details of database connections and basic data operations, allowing specific repositories to focus on their unique needs. It also helps to enforce a uniform approach to data access, making the codebase easier to understand and maintain. Using a base repository reduces the amount of boilerplate code required for each new repository, streamlining the development process and minimizing the risk of errors.
// Core/Interfaces
public interface IBaseRepository<T>
{
Task<T> GetByIdAsync(int id);
Task<IEnumerable<T>> GetAllAsync();
Task AddAsync(T entity);
Task UpdateAsync(T entity);
Task DeleteAsync(int id);
}
// Infrastructure
using Dapper;
using Microsoft.Extensions.Configuration;
using System.Data.SqlClient;
using System.Data;
public class BaseRepository<T> : IBaseRepository<T>
{
private readonly string _connectionString;
public BaseRepository(IConfiguration configuration)
{
_connectionString = configuration.GetConnectionString("DefaultConnection");
}
protected IDbConnection CreateConnection() => new SqlConnection(_connectionString);
public async Task<T> GetByIdAsync(int id)
{
using var connection = CreateConnection();
return await connection.QueryFirstOrDefaultAsync<T>("SELECT * FROM [" + typeof(T).Name + "] WHERE Id = @Id", new { Id = id });
}
public async Task<IEnumerable<T>> GetAllAsync()
{
using var connection = CreateConnection();
return await connection.QueryAsync<T>("SELECT * FROM [" + typeof(T).Name + "]");
}
public async Task AddAsync(T entity)
{
using var connection = CreateConnection();
// Implement your insert query here based on entity properties
// Example: INSERT INTO TableName (Property1, Property2) VALUES (@Property1, @Property2)
// Use Dapper's ExecuteAsync method
throw new NotImplementedException();
}
public async Task UpdateAsync(T entity)
{
using var connection = CreateConnection();
// Implement your update query here
// Example: UPDATE TableName SET Property1 = @Property1, Property2 = @Property2 WHERE Id = @Id
// Use Dapper's ExecuteAsync method
throw new NotImplementedException();
}
public async Task DeleteAsync(int id)
{
using var connection = CreateConnection();
await connection.ExecuteAsync("DELETE FROM [" + typeof(T).Name + "] WHERE Id = @Id", new { Id = id });
}
}
Implementing Specific Repositories
Now, let's create specific repositories for our entities: Route
, Station
, and Trip
. These repositories will inherit from the BaseRepository
and implement any entity-specific operations. Each repository class focuses on managing the data of a specific entity, ensuring a clear separation of concerns and promoting maintainability. By isolating data access logic, these repositories make it easier to test and modify data operations without affecting other parts of the application. They also provide a consistent interface for data access, simplifying the interaction between the application and the database. This approach reduces code duplication and improves the overall structure and readability of the codebase.
// Core/Interfaces
public interface IRouteRepository : IBaseRepository<Route> { }
public interface IStationRepository : IBaseRepository<Station> { }
public interface ITripRepository : IBaseRepository<Trip> { }
// Infrastructure
public class RouteRepository : BaseRepository<Route>, IRouteRepository
{
public RouteRepository(IConfiguration configuration) : base(configuration) { }
// Add Route specific methods here
}
public class StationRepository : BaseRepository<Station>, IStationRepository
{
public StationRepository(IConfiguration configuration) : base(configuration) { }
// Add Station specific methods here
}
public class TripRepository : BaseRepository<Trip>, ITripRepository
{
public TripRepository(IConfiguration configuration) : base(configuration) { }
// Add Trip specific methods here
}
Building Your First Feature: Route Management
Now that we have our infrastructure set up, let's build our first feature: Route Management. This will involve creating entities, services, and API endpoints for managing routes.
Creating Entities
Let's define our entities in the Core/Entities
folder. These entities represent the data structures we'll be working with.
// Core/Entities
public class Route
{
public int Id { get; set; }
public string Name { get; set; }
public string Description { get; set; }
}
public class Station
{
public int Id { get; set; }
public string Name { get; set; }
public string Address { get; set; }
}
public class Trip
{
public int Id { get; set; }
public int RouteId { get; set; }
public DateTime DepartureTime { get; set; }
}
Implementing Services
Next, we'll create services in the Application
layer to handle the business logic for route management. Services act as intermediaries between the API and the repositories, encapsulating business rules and logic. They are responsible for coordinating data access, validation, and any other operations required to fulfill a request. By centralizing business logic in services, we promote code reusability and maintainability, making it easier to test and modify the application's behavior. This separation of concerns ensures that the API layer remains thin and focused on handling HTTP requests and responses, while the services handle the core application logic.
// Core/Interfaces
public interface IRouteService
{
Task<Route> GetRouteByIdAsync(int id);
Task<IEnumerable<Route>> GetAllRoutesAsync();
Task AddRouteAsync(Route route);
Task UpdateRouteAsync(Route route);
Task DeleteRouteAsync(int id);
}
// Application
public class RouteService : IRouteService
{
private readonly IRouteRepository _routeRepository;
public RouteService(IRouteRepository routeRepository)
{
_routeRepository = routeRepository;
}
public async Task<Route> GetRouteByIdAsync(int id) => await _routeRepository.GetByIdAsync(id);
public async Task<IEnumerable<Route>> GetAllRoutesAsync() => await _routeRepository.GetAllAsync();
public async Task AddRouteAsync(Route route) => await _routeRepository.AddAsync(route);
public async Task UpdateRouteAsync(Route route) => await _routeRepository.UpdateAsync(route);
public async Task DeleteRouteAsync(int id) => await _routeRepository.DeleteAsync(id);
}
Creating API Endpoints
Finally, let's create API endpoints in the WebAPI
layer to expose our route management functionality. These endpoints will handle HTTP requests and delegate the work to the appropriate services.
// WebAPI/Controllers
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/[controller]")]
public class RoutesController : ControllerBase
{
private readonly IRouteService _routeService;
public RoutesController(IRouteService routeService)
{
_routeService = routeService;
}
[HttpGet("{id}")]
public async Task<IActionResult> GetRoute(int id)
{
var route = await _routeService.GetRouteByIdAsync(id);
if (route == null)
{
return NotFound();
}
return Ok(route);
}
[HttpGet]
public async Task<IActionResult> GetRoutes()
{
var routes = await _routeService.GetAllRoutesAsync();
return Ok(routes);
}
[HttpPost]
public async Task<IActionResult> CreateRoute([FromBody] Route route)
{
await _routeService.AddRouteAsync(route);
return CreatedAtAction(nameof(GetRoute), new { id = route.Id }, route);
}
[HttpPut("{id}")]
public async Task<IActionResult> UpdateRoute(int id, [FromBody] Route route)
{
if (id != route.Id)
{
return BadRequest();
}
await _routeService.UpdateRouteAsync(route);
return NoContent();
}
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteRoute(int id)
{
await _routeService.DeleteRouteAsync(id);
return NoContent();
}
}
Setting up Dependency Injection
To tie everything together, we need to set up dependency injection in our Startup.cs
file. This will allow us to inject our repositories and services into our controllers. Dependency injection (DI) is a design pattern that allows us to decouple our components, making our code more testable and maintainable. By injecting dependencies, we reduce the tight coupling between classes, making it easier to swap out implementations and change the behavior of our application. This promotes flexibility and allows us to adapt to changing requirements without major code rewrites. DI also facilitates unit testing by allowing us to mock dependencies, isolating the component being tested and ensuring that tests are focused and reliable. This practice leads to a more robust and resilient application.
// WebAPI/Startup.cs
public void ConfigureServices(IServiceCollection services)
{
services.AddControllers();
services.AddTransient<IRouteRepository, RouteRepository>();
services.AddTransient<IRouteService, RouteService>();
services.AddTransient<IStationRepository, StationRepository>();
services.AddTransient<ITripRepository, TripRepository>();
// Add configuration for database connection
services.AddSingleton<IConfiguration>(Configuration);
}
Constructor Injection & Interface Segregation
We've already touched on these principles, but let's reiterate their importance. Constructor injection makes our dependencies explicit, improving code readability and testability. By injecting dependencies through the constructor, we ensure that a class receives all the necessary components it needs to function correctly. This approach makes it clear what dependencies a class has, reducing the chances of runtime errors due to missing dependencies. It also facilitates unit testing, as we can easily mock dependencies and pass them into the constructor for testing purposes. Interface segregation ensures that our interfaces are focused and specific, preventing classes from implementing unnecessary methods. This principle promotes a more modular and maintainable codebase by breaking down large interfaces into smaller, more manageable ones. It helps to reduce the coupling between classes, as they only need to depend on the specific interfaces they require. Interface segregation also improves code clarity and makes it easier to understand the responsibilities of each interface.
Conclusion
Guys, we've covered a lot in this article! We've walked through setting up a Clean Architecture project, implementing Dapper-based repositories, and building our first feature: Route Management. This is just the beginning, though. With this foundation, you can continue to build out your application with confidence, knowing that you have a solid, maintainable architecture in place. Remember to keep practicing and exploring new ways to improve your code. Happy coding!