Clean Architecture Guide: Dapper & Route Management
Hey guys! Ever wondered how to build super maintainable and testable applications? Let's dive into Clean Architecture and see how we can combine it with Dapper and the Repository Pattern to create a robust application. This guide will walk you through setting up a clean architecture, implementing Dapper repositories, and building your first feature: Route Management. Buckle up, it's gonna be a fun ride!
Why Clean Architecture?
So, why should we even bother with Clean Architecture? Well, imagine building a house where the plumbing is tangled with the electrical wiring, and the foundation is made of cardboard. Sounds like a disaster, right? That's what happens when our code isn't well-organized. Clean Architecture helps us avoid this mess by providing a blueprint for structuring our applications in a way that makes them:
- Testable: We can easily test individual parts of our application without having to spin up the whole thing.
- Maintainable: Changes in one part of the application are less likely to break other parts.
- Independent of Frameworks: We're not tied to any specific framework or database, making it easier to switch things up later if needed.
- Independent of UI: The core logic of our application doesn't depend on the user interface, meaning we can have multiple UIs (e.g., a web app and a mobile app) sharing the same core.
In essence, Clean Architecture helps us build applications that are resilient to change and easy to understand. And who doesn't want that?
Key Principles of Clean Architecture
Before we jump into the code, let's quickly touch on the core principles of Clean Architecture:
- Dependency Inversion Principle (DIP): High-level modules shouldn't depend on low-level modules. Both should depend on abstractions. Abstractions shouldn't depend on details. Details should depend on abstractions. This is a mouthful, but it basically means we should use interfaces to decouple our components.
- Single Responsibility Principle (SRP): A class should have only one reason to change. In other words, it should have only one job.
- Open/Closed Principle (OCP): Software entities (classes, modules, functions, etc.) should be open for extension but closed for modification. We should be able to add new functionality without changing existing code.
- Liskov Substitution Principle (LSP): Subtypes must be substitutable for their base types. If a class inherits from another, it should be able to do everything the parent class can do.
- Interface Segregation Principle (ISP): Clients shouldn't be forced to depend on methods they don't use. We should create specific interfaces for clients instead of one general-purpose interface.
These principles might seem abstract now, but they'll become clearer as we start coding. Trust me!
Setting up the Project Structure
Alright, let's get our hands dirty! We'll start by creating the folder structure for our application. Following Clean Architecture, we'll have four main layers:
- Core: This is the heart of our application. It contains the entities, interfaces, and business logic that define our domain. It's completely independent of any external frameworks or technologies.
- Application: This layer contains use cases or application services that orchestrate the business logic in the Core layer. It also defines the application's workflow and transactions.
- Infrastructure: This layer is where we implement the details, like database access, external APIs, and other infrastructure concerns. It depends on the Core layer but not the other way around.
- WebAPI: This is our presentation layer, responsible for handling HTTP requests and responses. It depends on the Application layer to execute use cases.
Here's how our folder structure will look:
CleanArchitectureWithDapper/
βββ Core/
β βββ Entities/
β βββ Interfaces/
β βββ Exceptions/
βββ Application/
β βββ Interfaces/
β βββ Services/
β βββ Models/
βββ Infrastructure/
β βββ Data/
β βββ Repositories/
βββ WebAPI/
Each of these folders will contain the relevant files for its layer. This structure helps us keep our code organized and maintainable.
Implementing Dapper-based Repository Pattern
Now, let's talk about Dapper and the Repository Pattern. Dapper is a lightweight ORM (Object-Relational Mapper) that allows us to interact with the database using raw SQL queries. It's super fast and efficient, making it a great choice for performance-sensitive applications. The Repository Pattern is a design pattern that provides an abstraction layer between our application and the data access layer. This allows us to easily switch between different data sources (e.g., SQL Server, PostgreSQL, in-memory database) without changing our application code.
Why Dapper?
- Speed: Dapper is known for its speed and efficiency. It's much faster than full-fledged ORMs like Entity Framework because it doesn't do as much behind the scenes.
- Control: Dapper gives us full control over our SQL queries. We're not limited by the ORM's query language.
- Simplicity: Dapper is easy to learn and use. It's basically just an extension to the
IDbConnection
interface.
The Repository Pattern
The Repository Pattern helps us decouple our application logic from the data access logic. It provides a clean and consistent way to access data, regardless of the underlying data source. Here's how it works:
- We define an interface for our repository (e.g.,
IRouteRepository
). - We create a concrete implementation of the interface using Dapper (e.g.,
DapperRouteRepository
). - Our application services use the repository interface to access data.
This allows us to easily swap out the data access implementation without affecting the rest of our application. For instance, we can switch from using Dapper with SQL Server to using an in-memory database for testing without changing any code in our application services.
Building Our First Feature: Route Management
Okay, enough theory! Let's build our first feature: Route Management. This will involve creating entities, implementing services using Dapper, and using constructor injection and interface segregation.
1. Creating Entities
First, we need to define our entities. Entities are simple classes that represent the data we're working with. In our case, we'll have three entities: Route
, Station
, and Trip
. Let's create these in the Core/Entities
folder:
// Core/Entities/Route.cs
public class Route
{
public int Id { get; set; }
public string Name { get; set; }
public int OriginStationId { get; set; }
public int DestinationStationId { get; set; }
}
// Core/Entities/Station.cs
public class Station
{
public int Id { get; set; }
public string Name { get; set; }
}
// Core/Entities/Trip.cs
public class Trip
{
public int Id { get; set; }
public int RouteId { get; set; }
public DateTime DepartureTime { get; set; }
}
These entities are simple POCOs (Plain Old CLR Objects) with properties representing the data we need.
2. Implementing Services using Dapper
Now, let's implement our services using Dapper. We'll start by defining the repository interfaces in the Core/Interfaces
folder:
// Core/Interfaces/IRouteRepository.cs
public interface IRouteRepository
{
Task<Route> GetByIdAsync(int id);
Task<IEnumerable<Route>> GetAllAsync();
Task AddAsync(Route route);
Task UpdateAsync(Route route);
Task DeleteAsync(int id);
}
// Core/Interfaces/IStationRepository.cs
public interface IStationRepository
{
Task<Station> GetByIdAsync(int id);
Task<IEnumerable<Station>> GetAllAsync();
Task AddAsync(Station station);
Task UpdateAsync(Station station);
Task DeleteAsync(int id);
}
// Core/Interfaces/ITripRepository.cs
public interface ITripRepository
{
Task<Trip> GetByIdAsync(int id);
Task<IEnumerable<Trip>> GetAllAsync();
Task AddAsync(Trip trip);
Task UpdateAsync(Trip trip);
Task DeleteAsync(int id);
}
These interfaces define the operations we can perform on our entities. Now, let's create the concrete implementations using Dapper in the Infrastructure/Repositories
folder:
// Infrastructure/Repositories/DapperRouteRepository.cs
public class DapperRouteRepository : IRouteRepository
{
private readonly IDbConnection _connection;
public DapperRouteRepository(IDbConnection connection)
{
_connection = connection;
}
public async Task<Route> GetByIdAsync(int id)
{
const string sql = "SELECT * FROM Routes WHERE Id = @Id";
return await _connection.QueryFirstOrDefaultAsync<Route>(sql, new { Id = id });
}
public async Task<IEnumerable<Route>> GetAllAsync()
{
const string sql = "SELECT * FROM Routes";
return await _connection.QueryAsync<Route>(sql);
}
public async Task AddAsync(Route route)
{
const string sql = "INSERT INTO Routes (Name, OriginStationId, DestinationStationId) VALUES (@Name, @OriginStationId, @DestinationStationId)";
await _connection.ExecuteAsync(sql, route);
}
public async Task UpdateAsync(Route route)
{
const string sql = "UPDATE Routes SET Name = @Name, OriginStationId = @OriginStationId, DestinationStationId = @DestinationStationId WHERE Id = @Id";
await _connection.ExecuteAsync(sql, route);
}
public async Task DeleteAsync(int id)
{
const string sql = "DELETE FROM Routes WHERE Id = @Id";
await _connection.ExecuteAsync(sql, new { Id = id });
}
}
// Infrastructure/Repositories/DapperStationRepository.cs
public class DapperStationRepository : IStationRepository
{
private readonly IDbConnection _connection;
public DapperStationRepository(IDbConnection connection)
{
_connection = connection;
}
public async Task<Station> GetByIdAsync(int id)
{
const string sql = "SELECT * FROM Stations WHERE Id = @Id";
return await _connection.QueryFirstOrDefaultAsync<Station>(sql, new { Id = id });
}
public async Task<IEnumerable<Station>> GetAllAsync()
{
const string sql = "SELECT * FROM Stations";
return await _connection.QueryAsync<Station>(sql);
}
public async Task AddAsync(Station station)
{
const string sql = "INSERT INTO Stations (Name) VALUES (@Name)";
await _connection.ExecuteAsync(sql, station);
}
public async Task UpdateAsync(Station station)
{
const string sql = "UPDATE Stations SET Name = @Name WHERE Id = @Id";
await _connection.ExecuteAsync(sql, station);
}
public async Task DeleteAsync(int id)
{
const string sql = "DELETE FROM Stations WHERE Id = @Id";
await _connection.ExecuteAsync(sql, new { Id = id });
}
}
// Infrastructure/Repositories/DapperTripRepository.cs
public class DapperTripRepository : ITripRepository
{
private readonly IDbConnection _connection;
public DapperTripRepository(IDbConnection connection)
{
_connection = connection;
}
public async Task<Trip> GetByIdAsync(int id)
{
const string sql = "SELECT * FROM Trips WHERE Id = @Id";
return await _connection.QueryFirstOrDefaultAsync<Trip>(sql, new { Id = id });
}
public async Task<IEnumerable<Trip>> GetAllAsync()
{
const string sql = "SELECT * FROM Trips";
return await _connection.QueryAsync<Trip>(sql);
}
public async Task AddAsync(Trip trip)
{
const string sql = "INSERT INTO Trips (RouteId, DepartureTime) VALUES (@RouteId, @DepartureTime)";
await _connection.ExecuteAsync(sql, trip);
}
public async Task UpdateAsync(Trip trip)
{
const string sql = "UPDATE Trips SET RouteId = @RouteId, DepartureTime = @DepartureTime WHERE Id = @Id";
await _connection.ExecuteAsync(sql, trip);
}
public async Task DeleteAsync(int id)
{
const string sql = "DELETE FROM Trips WHERE Id = @Id";
await _connection.ExecuteAsync(sql, new { Id = id });
}
}
Notice how we're using Dapper's QueryFirstOrDefaultAsync
, QueryAsync
, and ExecuteAsync
methods to interact with the database. We're also using parameterized queries to prevent SQL injection attacks.
3. Use Constructor Injection & Interface Segregation
In the repository implementations, we're using constructor injection to inject the IDbConnection
. This allows us to easily swap out the database connection for testing or other purposes. We're also adhering to the Interface Segregation Principle by creating specific repository interfaces for each entity (IRouteRepository
, IStationRepository
, ITripRepository
). This ensures that our clients only depend on the methods they need.
4. Implementing Application Services
Now that we have our repositories, let's create the application services that will use them. We'll define interfaces for our services in the Application/Interfaces
folder:
// Application/Interfaces/IRouteService.cs
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/Interfaces/IStationService.cs
public interface IStationService
{
Task<Station> GetStationByIdAsync(int id);
Task<IEnumerable<Station>> GetAllStationsAsync();
Task AddStationAsync(Station station);
Task UpdateStationAsync(Station station);
Task DeleteStationAsync(int id);
}
// Application/Interfaces/ITripService.cs
public interface ITripService
{
Task<Trip> GetTripByIdAsync(int id);
Task<IEnumerable<Trip>> GetAllTripsAsync();
Task AddTripAsync(Trip trip);
Task UpdateTripAsync(Trip trip);
Task DeleteTripAsync(int id);
}
And the concrete implementations in the Application/Services
folder:
// Application/Services/RouteService.cs
public class RouteService : IRouteService
{
private readonly IRouteRepository _routeRepository;
public RouteService(IRouteRepository routeRepository)
{
_routeRepository = routeRepository;
}
public async Task<Route> GetRouteByIdAsync(int id)
{
return await _routeRepository.GetByIdAsync(id);
}
public async Task<IEnumerable<Route>> GetAllRoutesAsync()
{
return 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);
}
}
// Application/Services/StationService.cs
public class StationService : IStationService
{
private readonly IStationRepository _stationRepository;
public StationService(IStationRepository stationRepository)
{
_stationRepository = stationRepository;
}
public async Task<Station> GetStationByIdAsync(int id)
{
return await _stationRepository.GetByIdAsync(id);
}
public async Task<IEnumerable<Station>> GetAllStationsAsync()
{
return await _stationRepository.GetAllAsync();
}
public async Task AddStationAsync(Station station)
{
await _stationRepository.AddAsync(station);
}
public async Task UpdateStationAsync(Station station)
{
await _stationRepository.UpdateAsync(station);
}
public async Task DeleteStationAsync(int id)
{
await _stationRepository.DeleteAsync(id);
}
}
// Application/Services/TripService.cs
public class TripService : ITripService
{
private readonly ITripRepository _tripRepository;
public TripService(ITripRepository tripRepository)
{
_tripRepository = tripRepository;
}
public async Task<Trip> GetTripByIdAsync(int id)
{
return await _tripRepository.GetByIdAsync(id);
}
public async Task<IEnumerable<Trip>> GetAllTripsAsync()
{
return await _tripRepository.GetAllAsync();
}
public async Task AddTripAsync(Trip trip)
{
await _tripRepository.AddAsync(trip);
}
public async Task UpdateTripAsync(Trip trip)
{
await _tripRepository.UpdateAsync(trip);
}
public async Task DeleteTripAsync(int id)
{
await _tripRepository.DeleteAsync(id);
}
}
Again, we're using constructor injection to inject the repository interfaces. This allows us to easily test our services by mocking the repositories.
5. Exposing the API in WebAPI
Finally, let's expose our services through a Web API. We'll create controllers in the WebAPI
project that use the application services to handle HTTP requests.
First, you'll need to register the services and repositories in your Startup.cs
or Program.cs
file (depending on your .NET version):
// In Startup.cs or Program.cs
builder.Services.AddTransient<IRouteService, RouteService>();
builder.Services.AddTransient<IRouteRepository, DapperRouteRepository>();
builder.Services.AddTransient<IStationService, StationService>();
builder.Services.AddTransient<IStationRepository, DapperStationRepository>();
builder.Services.AddTransient<ITripService, TripService>();
builder.Services.AddTransient<ITripRepository, DapperTripRepository>();
builder.Services.AddTransient<IDbConnection>(sp => new SqlConnection(builder.Configuration.GetConnectionString("DefaultConnection")));
Then, create the controllers:
// WebAPI/Controllers/RoutesController.cs
[ApiController]
[Route("api/[controller]")]
public class RoutesController : ControllerBase
{
private readonly IRouteService _routeService;
public RoutesController(IRouteService routeService)
{
_routeService = routeService;
}
[HttpGet]
public async Task<IActionResult> GetAll()
{
var routes = await _routeService.GetAllRoutesAsync();
return Ok(routes);
}
[HttpGet("{id}")]
public async Task<IActionResult> GetById(int id)
{
var route = await _routeService.GetRouteByIdAsync(id);
if (route == null)
{
return NotFound();
}
return Ok(route);
}
[HttpPost]
public async Task<IActionResult> Create(Route route)
{
await _routeService.AddRouteAsync(route);
return CreatedAtAction(nameof(GetById), new { id = route.Id }, route);
}
[HttpPut("{id}")]
public async Task<IActionResult> Update(int id, Route route)
{
if (id != route.Id)
{
return BadRequest();
}
await _routeService.UpdateRouteAsync(route);
return NoContent();
}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(int id)
{
await _routeService.DeleteRouteAsync(id);
return NoContent();
}
}
// WebAPI/Controllers/StationsController.cs
[ApiController]
[Route("api/[controller]")]
public class StationsController : ControllerBase
{
private readonly IStationService _stationService;
public StationsController(IStationService stationService)
{
_stationService = stationService;
}
[HttpGet]
public async Task<IActionResult> GetAll()
{
var stations = await _stationService.GetAllStationsAsync();
return Ok(stations);
}
[HttpGet("{id}")]
public async Task<IActionResult> GetById(int id)
{
var station = await _stationService.GetStationByIdAsync(id);
if (station == null)
{
return NotFound();
}
return Ok(station);
}
[HttpPost]
public async Task<IActionResult> Create(Station station)
{
await _stationService.AddStationAsync(station);
return CreatedAtAction(nameof(GetById), new { id = station.Id }, station);
}
[HttpPut("{id}")]
public async Task<IActionResult> Update(int id, Station station)
{
if (id != station.Id)
{
return BadRequest();
}
await _stationService.UpdateStationAsync(station);
return NoContent();
}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(int id)
{
await _stationService.DeleteStationAsync(id);
return NoContent();
}
}
// WebAPI/Controllers/TripsController.cs
[ApiController]
[Route("api/[controller]")]
public class TripsController : ControllerBase
{
private readonly ITripService _tripService;
public TripsController(ITripService tripService)
{
_tripService = tripService;
}
[HttpGet]
public async Task<IActionResult> GetAll()
{
var trips = await _tripService.GetAllTripsAsync();
return Ok(trips);
}
[HttpGet("{id}")]
public async Task<IActionResult> GetById(int id)
{
var trip = await _tripService.GetTripByIdAsync(id);
if (trip == null)
{
return NotFound();
}
return Ok(trip);
}
[HttpPost]
public async Task<IActionResult> Create(Trip trip)
{
await _tripService.AddTripAsync(trip);
return CreatedAtAction(nameof(GetById), new { id = trip.Id }, trip);
}
[HttpPut("{id}")]
public async Task<IActionResult> Update(int id, Trip trip)
{
if (id != trip.Id)
{
return BadRequest();
}
await _tripService.UpdateTripAsync(trip);
return NoContent();
}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(int id)
{
await _tripService.DeleteTripAsync(id);
return NoContent();
}
}
These controllers use constructor injection to get instances of our application services and then use those services to handle HTTP requests. We're using the [ApiController]
attribute to enable automatic model validation and other features.
Conclusion
And there you have it! We've built a Clean Architecture application with Dapper repositories and implemented our first feature: Route Management. We covered a lot of ground, including setting up the project structure, implementing the Repository Pattern with Dapper, and creating entities, services, and controllers.
This is just the beginning, of course. There's much more to Clean Architecture and Dapper than we could cover here. But hopefully, this guide has given you a solid foundation for building maintainable and testable applications. Keep experimenting, keep learning, and happy coding!