A Big Thank You for Your Support! 🙏
Before we dive deeper into today's topic, I just want to take a moment to thank all of you who have read and engaged with my previous posts! Your positive feedback, comments, and questions have been incredibly motivating, and I’m thrilled to know that these articles are helping you in your journey of learning and development. 🌟
Your support and interest in these topics make all the effort worthwhile, and I’m truly grateful for each one of you who took the time to read and share your thoughts. 💬
If you’re enjoying the series and are interested in learning more about other concepts or related topics, feel free to leave a comment below! Let me know what else you’d like to see in future posts—I'm always eager to continue the conversation and share more insights. 💡
Template for Clean Domain-Driven Design Architecture 🎯
The main purpose of this blog post is to provide a template for building web applications based on Clean Domain-Driven Design (DDD) principles and Command Query Responsibility Segregation (CQRS) concepts. This template is my personal interpretation of these methodologies, which I've crafted from my extensive study of best practices in the field. 📚 Throughout my exploration of DDD and CQRS, I have encountered several approaches, and I've synthesized these into a structure that I believe offers practical value.
Purpose & Guiding Principles
The goal of this blog post is not to present a "one-size-fits-all" solution, but rather to offer guidelines and advice for implementing these architectural patterns in real-world projects. 🌍 It’s designed to provide direction and inspiration for developers and small teams who want to build maintainable, scalable applications using modern software design principles.
Who This is For
This template is particularly tailored for:
- Developers working in a fast-paced, agile environment 🚀
- Small teams or startups 🏢
- Organizations looking to balance flexibility and scalability 🌱
The focus is on creating an architecture that can evolve over time. Continuous improvement is emphasized, meaning that as new challenges or requirements arise, the architecture can adapt and grow alongside them. 🌟
Not a Universal Solution
As with any architectural approach, this template might not suit every project or every team. 🔄 Different contexts require different solutions. That’s why I always recommend conducting your own analysis before adopting any model. ⚖️ Assess the specific needs of your project, team size, and organizational structure to determine the best path forward.
Flexibility is Key
The strength of this architecture lies in its flexibility and ability to scale over time. 🌍 As your application grows, so too should your architecture, adapting to the demands of the business and the ever-changing landscape of technology.
By embracing the principles of DDD and CQRS, you can build a system that is both robust and maintainable, allowing your application to grow without becoming a tangled mess of code. ✨
Core Layers 🏛️
In any well-structured software architecture, the separation of concerns is crucial. It ensures that different components of the system can evolve independently while keeping the system cohesive and maintainable. The Domain and Application layers play a vital role in organizing the architecture, ensuring that your system is scalable, adaptable, and ready to meet both current and future business needs. In this section, we'll explore these core layers in greater detail.
Domain Layer Organization ⚙️
The Domain Layer is arguably the most important layer in any application built using Domain-Driven Design (DDD). It represents the business heart of the application—the set of entities, business rules, and domain logic that dictate how the system operates.
Key characteristics of the Domain Layer:
-
Single Domain Project: The Domain Layer should reside in a single project, and it should not depend on any external libraries, frameworks, or infrastructure. The Domain Layer is pure in that it should not be influenced by implementation concerns. This ensures that business logic remains independent and is focused purely on solving business problems, rather than dealing with technical issues like data persistence or UI rendering. For example, a
Payroll
entity in the Domain Layer would contain business rules about salary calculation and not worry about how it’s persisted in the database. 🏆
-
Abstractions Folder: The Abstractions folder in the Domain Layer contains the interfaces for repositories, services, or other components that the Domain needs to interact with. These interfaces will later be implemented by components in the Application Layer or infrastructure layer. This separation of concerns allows for better decoupling, enabling the Domain Layer to focus solely on the business logic. For example, an
IEmployeeRepository
interface might reside here, which the Application Layer will implement to interact with the database. These interfaces are also critical for unit testing, as they can be mocked or replaced with stubs during testing. 🔗
-
Common Folder: The Common folder is meant for shared domain entities, value objects, and logic that spans multiple bounded contexts or modules. For instance, concepts like
Address
,PhoneNumber
, orMoney
might be shared across several business areas like HR, Accounting, and Sales. Storing these shared concepts in a central location helps prevent duplication and promotes consistency across different parts of the application. It’s worth noting that these common objects should be domain-centric, not infrastructure-specific, as they are fundamental building blocks for the business logic. 📦
-
Bounded Contexts: In DDD, a bounded context defines the boundaries within which a particular domain model is valid. Each bounded context has its own set of business rules and logic. As your application grows, the Domain Layer might evolve to include multiple bounded contexts. For example, the
HumanResources
context might manage employee data and benefits, while theAccounting
context might manage payroll and financial transactions. These contexts could eventually be split into separate projects or services, especially in microservices architectures. 📚
One important principle is that the Domain Layer should never depend on external infrastructure (such as databases or frameworks). The Domain Layer is intended to remain pure, focusing exclusively on business logic. The only external dependency that the Domain Layer might need is for dispatching domain events via a library like MediatR. This allows the Domain Layer to communicate with other parts of the system asynchronously without introducing tight coupling. 🔒
Application Layer Organization 🛠️
The Application Layer serves as the orchestrator between the Domain Layer and external systems or services. While the Domain Layer contains the business logic, the Application Layer coordinates the interaction of that logic with the outside world—whether it’s a database, third-party service, or user interface.
Key points about the Application Layer:
-
Abstractions Folder: Just like in the Domain Layer, the Application Layer also has an Abstractions folder. Here, we define the interfaces for components like services, command handlers, or queries that will interact with the Domain Layer. These interfaces allow the Application Layer to remain decoupled from specific implementations and dependencies, facilitating testing and flexibility. For example, an interface like
IPayrollService
could reside in the Abstractions folder, representing operations that the Application Layer exposes for processing payroll. This separation ensures that different implementations or changes in technology (like switching from EF Core to Dapper) won’t affect the Domain Layer. 🧩
- Common Folder: The Common folder in the Application Layer serves a similar purpose as the one in the Domain Layer. It houses code that is shared across multiple use cases, such as error handling, logging, and validation. For example, if you need to validate employee data before saving it to the database, the validation logic would be located in this folder. The goal here is to provide reusable, shared components that can be leveraged across various use cases or features of the application. 🌍
- Interaction with Persistence: One of the primary roles of the Application Layer is to interact with the Persistence Layer. This layer contains the repositories or data access objects (DAOs) that handle the saving, updating, and retrieving of domain entities from a database. In this architecture, we often use Entity Framework Core (EF Core) or Dapper for this task. The Application Layer coordinates the flow of data between the Domain Layer and the Persistence Layer, ensuring that the correct business logic is applied when data is stored or retrieved.
For example, when an employee is hired, the Application Layer will handle the use case of creating an Employee
record. It will then delegate the persistence of the employee entity to the EmployeeRepository
using EF Core. The Application Layer ensures that business rules (from the Domain Layer) are respected, such as validating that the employee’s salary is within the allowed range before saving it. 💾
- Command and Query Responsibility Segregation (CQRS): In this architecture, we follow the CQRS pattern, which separates the logic for modifying data (commands) from the logic for reading data (queries). The Application Layer handles both commands and queries, but they are clearly separated into different handlers. For instance, a command handler might handle the creation of a new employee, while a query handler retrieves employee information for reporting purposes. This separation allows for scaling each operation independently and optimizing them based on their unique performance characteristics. Command-side operations might involve more complex business logic, whereas query-side operations can be optimized for speed, often using read-optimized databases or caching strategies. 📈
Insights & Tips 💭
Separation of Concerns: One of the key benefits of organizing your application into clear Domain and Application Layers is the separation of concerns. The Domain Layer is focused on pure business logic, while the Application Layer handles external interactions (such as persistence and services). This makes your code more modular, testable, and maintainable. By keeping these concerns separate, you avoid the problem of business logic becoming tightly coupled with technical concerns, which can lead to fragile and hard-to-maintain code over time. 🔨
Start Simple, Evolve Gradually: In the early stages of a project, you may not need to implement all the advanced architectural patterns or separation strategies. It's perfectly fine to start with a simple architecture that focuses on delivering features quickly. As your application grows, you can introduce CQRS, event-driven architectures, and domain events to decouple components and manage complexity. Begin with basic CRUD operations, then evolve the architecture as business requirements become more complex. 🛠️
Event-Driven Design: As your system grows, consider implementing an event-driven architecture using domain events and event handlers. This allows different parts of your application to react to significant changes in the system (e.g., an employee being onboarded) without tightly coupling different components. An event-driven approach leads to greater decoupling and scalability as your system expands. 🔄
Testability and Maintainability: Clear separation between layers improves testability by enabling unit testing each layer in isolation. The Domain Layer can be tested for business logic, while the Application Layer can be tested for orchestration and interaction with external services. This ensures that the system remains robust and flexible, and new features can be added without fear of breaking existing functionality. 🧪
The following code demonstrates the practical implementation of CQRS (Command Query Responsibility Segregation) using MediatR and Dapper for handling both command and query operations in a streamlined manner.
// Command to register a new employee
public class RegisterEmployeeCommand : IRequest<bool>
{
public string Name { get; set; }
public string Position { get; set; }
public DateTime DateOfJoining { get; set; }
}
// Command Handler for registering an employee
public class RegisterEmployeeCommandHandler : IRequestHandler<RegisterEmployeeCommand, bool>
{
private readonly IDatabaseService _databaseService;
// Injecting database service dependency
public RegisterEmployeeCommandHandler(IDatabaseService databaseService)
{
_databaseService = databaseService;
}
// Handling the command - inserts a new employee into the database
public async Task<bool> Handle(RegisterEmployeeCommand request, CancellationToken cancellationToken)
{
try
{
// SQL query to insert employee data
var query = "INSERT INTO Employees (Name, Position, DateOfJoining) VALUES (@Name, @Position, @DateOfJoining)";
var parameters = new { request.Name, request.Position, request.DateOfJoining };
// Execute the query asynchronously and return whether it succeeded
var result = await _databaseService.ExecuteAsync(query, parameters);
// If the result is greater than 0, insertion was successful
return result > 0;
}
catch (Exception ex)
{
// Handle the exception, possibly logging it
// log.Error(ex, "Error occurred while registering employee.");
// In real-world scenarios, use logging frameworks like Serilog or NLog here
return false; // Return false on failure
}
}
}
// Query to fetch employee details
public class GetEmployeeDetailsQuery : IRequest<Employee>
{
public int EmployeeId { get; set; }
}
// Query Handler to fetch employee details
public class GetEmployeeDetailsQueryHandler : IRequestHandler<GetEmployeeDetailsQuery, Employee>
{
private readonly IDatabaseService _databaseService;
// Injecting database service dependency
public GetEmployeeDetailsQueryHandler(IDatabaseService databaseService)
{
_databaseService = databaseService;
}
// Handling the query - retrieves employee details by ID
public async Task<Employee> Handle(GetEmployeeDetailsQuery request, CancellationToken cancellationToken)
{
try
{
// SQL query to get employee details by Id
var query = "SELECT * FROM Employees WHERE Id = @EmployeeId";
// Execute the query asynchronously and return the employee object
var employee = await _databaseService.QueryFirstOrDefaultAsync<Employee>(query, new { request.EmployeeId });
return employee;
}
catch (Exception ex)
{
// Handle the exception, possibly logging it
// log.Error(ex, "Error occurred while fetching employee details.");
// Use a logging framework to capture errors for diagnostics
return null; // Return null if there is an error
}
}
}
RegisterEmployeeCommand
The RegisterEmployeeCommand
class represents a command to register a new employee, containing the necessary details like Name
, Position
, and DateOfJoining
that are essential for adding an employee to the system.
RegisterEmployeeCommandHandler
The RegisterEmployeeCommandHandler
is responsible for processing this command, interacting with the database through the IDatabaseService
. It executes an asynchronous SQL INSERT
query to add the employee's details to the database. If the operation is successful, the handler returns true
; otherwise, it catches any exceptions and returns false
.
GetEmployeeDetailsQuery
Similarly, the GetEmployeeDetailsQuery
represents a query to retrieve an employee’s details using their EmployeeId
.
GetEmployeeDetailsQueryHandler
The GetEmployeeDetailsQueryHandler
handles the query by executing a SQL SELECT
query to fetch the employee's record from the database. If the employee is found, the handler returns the Employee
object; if not, it returns null
.
Database Interaction
Both handlers interact with the database asynchronously using the IDatabaseService
, ensuring non-blocking operations.
Benefits of CQRS
This structure follows the CQRS pattern, which separates the concerns of reading data (queries) and writing data (commands), providing clear modularity. This separation not only ensures a clean architecture but also improves the maintainability and scalability of the application.
Exception Handling
Additionally, the code includes exception handling to manage errors gracefully, with both command and query handlers returning appropriate values like false
or null
when issues arise. This approach facilitates a robust and efficient system architecture.
Application Layer: Orchestrating Business Logic and Data Flow ⚡
The Application Layer plays a vital role in orchestrating the application's core operations. It ensures a smooth interaction between the Domain Layer, Persistence Layer, and External Services. The Application Layer is responsible for validation, transaction management, and data transformation between the domain model and persistence layer. By coordinating these components, it ensures that commands and queries are processed effectively while maintaining clean separation of concerns.
Responsibilities of the Application Layer 🛠️
The primary tasks of the Application Layer include:
Handling Business Use Cases: The Application Layer encapsulates and orchestrates business logic. This is often achieved through the use of commands (write operations) and queries (read operations).
Validation: The Application Layer validates incoming commands and queries to ensure that they meet the necessary criteria before business logic is applied.
Transaction Management: It manages transactions to ensure that operations are executed consistently and atomically.
Data Transformation: It transforms data between the domain model and persistence layer, ensuring that the right data format is passed between components.
Error Handling: The Application Layer includes mechanisms to handle errors, such as validating commands and rolling back operations if something fails during the transaction.
Handling Use Cases and Commands 📝
One of the most critical responsibilities of the Application Layer is to manage business use cases, which are encapsulated as commands and queries. In CQRS (Command Query Responsibility Segregation) architecture, the system is split into read and write models to optimize each for its respective concern.
- Commands handle write operations (e.g., creating or updating data).
- Queries handle read operations (e.g., retrieving data).
This separation allows for better performance, scalability, and maintainability.
Command Processing 🛠️
When a user or service sends a command to the system, the Application Layer orchestrates the processing of that command. Let's break down the steps involved in processing a command:
1. Command Validation 🔍
Before executing any business logic, the Application Layer validates the command to ensure the input is correct. A validation framework like FluentValidation can be used for this purpose. It checks if the data provided adheres to the expected format and ensures that all required fields are filled in.
For example:
- Ensuring that an employee’s name is not empty.
- Verifying that the date of joining is valid (e.g., not a future date).
- Checking that a payroll amount is within acceptable limits.
Validating commands upfront prevents invalid data from entering the business logic or database, which helps maintain system integrity.
2. Command Handler 🔧
Once the command is validated, it’s passed to the Command Handler. The handler is responsible for executing the actual business logic. It performs tasks such as:
- Persisting data: The handler interacts with the Persistence Layer (using Dapper or Entity Framework Core) to save data to the database.
- Business Rules: It enforces necessary business rules, such as validating employee status, calculating payroll amounts, or updating other related data.
- Interaction with the Domain Layer: The handler might invoke domain logic or models, like creating a new employee or calculating an employee’s compensation.
The handler ensures that commands are processed in a consistent and predictable manner.
3. Persistence Layer 💾
After the command has been processed, the Persistence Layer commits changes to the database. Depending on the system's needs, this could be done using Entity Framework Core (ideal for complex data models and relationships) or Dapper (a lightweight ORM for optimized querying).
- Entity Framework Core: Ideal for applications that require complex relational mappings or Object-Relational Mapping (ORM) solutions.
- Dapper: A faster, lightweight ORM suited for applications where performance is critical, especially for simple queries or read-heavy systems.
The persistence layer ensures that the changes made in the domain model are reflected in the database.
Example of Command Processing:
public class RegisterEmployeeCommandHandler : IRequestHandler<RegisterEmployeeCommand, bool>
{
private readonly IDatabaseService _databaseService;
private readonly IValidator<RegisterEmployeeCommand> _validator;
public RegisterEmployeeCommandHandler(IDatabaseService databaseService, IValidator<RegisterEmployeeCommand> validator)
{
_databaseService = databaseService;
_validator = validator;
}
public async Task<bool> Handle(RegisterEmployeeCommand request, CancellationToken cancellationToken)
{
// Validate the command
var validationResult = await _validator.ValidateAsync(request);
if (!validationResult.IsValid)
{
throw new ValidationException(validationResult.Errors);
}
// Command logic (Insert employee data into the database)
var query = "INSERT INTO Employees (Name, Position, DateOfJoining) VALUES (@Name, @Position, @DateOfJoining)";
var parameters = new { request.Name, request.Position, request.DateOfJoining };
return await _databaseService.ExecuteAsync(query, parameters);
}
}
// FluentValidation for validating commands
public class RegisterEmployeeCommandValidator : AbstractValidator<RegisterEmployeeCommand>
{
public RegisterEmployeeCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().WithMessage("Employee name is required.");
RuleFor(x => x.Position).NotEmpty().WithMessage("Employee position is required.");
RuleFor(x => x.DateOfJoining).LessThan(DateTime.Now).WithMessage("Join date must be in the past.");
}
}
// Register Employee Command Handler
public class RegisterEmployeeCommandHandler : IRequestHandler<RegisterEmployeeCommand, bool>
{
private readonly IDatabaseService _databaseService;
private readonly IValidator<RegisterEmployeeCommand> _validator;
public RegisterEmployeeCommandHandler(IDatabaseService databaseService, IValidator<RegisterEmployeeCommand> validator)
{
_databaseService = databaseService;
_validator = validator;
}
public async Task<bool> Handle(RegisterEmployeeCommand request, CancellationToken cancellationToken)
{
// Validate the command
var validationResult = await _validator.ValidateAsync(request);
if (!validationResult.IsValid)
{
throw new ValidationException(validationResult.Errors);
}
// Command logic
var query = "INSERT INTO Employees (Name, Position, DateOfJoining) VALUES (@Name, @Position, @DateOfJoining)";
var parameters = new { request.Name, request.Position, request.DateOfJoining };
return await _databaseService.ExecuteAsync(query, parameters);
}
}
Query Processing 🔍
On the other hand, query processing is primarily focused on retrieving data without modifying the system state. Queries are handled in a similar fashion but are optimized for reading operations. Here's how it works:
Query Handler:
The Query Handler is responsible for fetching data based on a provided query. The handler interacts with the Persistence Layer to retrieve the required data and return it to the caller.
Data Transformation:
Often, data returned from the database is not in a format suitable for the frontend. In such cases, the Application Layer can transform the data using libraries like AutoMapper or Mapster, which allow you to map database entities to view models suited for presentation.
Example of Query Processing:
public class GetEmployeeDetailsQueryHandler : IRequestHandler<GetEmployeeDetailsQuery, EmployeeDetailsViewModel>
{
private readonly IDatabaseService _databaseService;
private readonly IMapper _mapper;
public GetEmployeeDetailsQueryHandler(IDatabaseService databaseService, IMapper mapper)
{
_databaseService = databaseService;
_mapper = mapper;
}
public async Task<EmployeeDetailsViewModel> Handle(GetEmployeeDetailsQuery request, CancellationToken cancellationToken)
{
var query = "SELECT Id, Name, Position, DateOfJoining FROM Employees WHERE Id = @EmployeeId";
var employee = await _databaseService.QueryFirstOrDefaultAsync<Employee>(query, new { request.EmployeeId });
if (employee == null)
{
throw new EntityNotFoundException("Employee not found");
}
return _mapper.Map<EmployeeDetailsViewModel>(employee);
}
}
Managing Transactions in the Application Layer 🧾
For more complex operations that involve multiple steps (like creating an employee and then processing their payroll), transaction management becomes critical. In such cases, the Application Layer is responsible for managing transactions and ensuring consistency.
Example of Transaction Management:
public class RegisterEmployeeCommandHandler : IRequestHandler<RegisterEmployeeCommand, bool>
{
private readonly IUnitOfWork _unitOfWork;
public RegisterEmployeeCommandHandler(IUnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork;
}
public async Task<bool> Handle(RegisterEmployeeCommand request, CancellationToken cancellationToken)
{
using (var transaction = await _unitOfWork.BeginTransactionAsync())
{
try
{
// Command logic (register employee)
var success = await _unitOfWork.Employees.AddAsync(new Employee(request.Name, request.Position));
if (!success)
throw new InvalidOperationException("Could not register employee.");
await transaction.CommitAsync();
return true;
}
catch (Exception)
{
await transaction.RollbackAsync();
throw;
}
}
}
}
This ensures that the entire operation either succeeds or fails as a whole, maintaining system consistency.
Cross-Cutting Concerns 🔒
The Application Layer also manages various cross-cutting concerns that apply across the entire system, such as:
- Logging: Using libraries like Serilog to log important system events for debugging and tracking purposes.
- Security and Authorization: While security concerns are often handled at the API layer, the Application Layer may also enforce higher-level security policies.
- Caching: Caching frequently accessed data to improve performance.
- Exception Handling: Managing errors and ensuring that they are properly logged and handled.
Key Takeaways 💡
- The Application Layer is crucial for decoupling business logic, validation, and persistence operations.
- It manages both commands (write operations) and queries (read operations) to optimize the system's performance and scalability.
- Validation ensures that only valid data is processed, maintaining integrity.
- Transactions and error handling are essential for managing consistency and reliability in the system.
- The Application Layer is responsible for orchestrating interactions between the Domain Layer, Persistence Layer, and External Services while maintaining clear separation of concerns.
DDD and EF Core: A Harmonious Separation 🚀
Domain-Driven Design (DDD) and Entity Framework Core (EF Core) serve distinct purposes in software development. DDD is a methodology for modeling the business domain, while EF Core is an Object-Relational Mapper (ORM) used for database interactions. While DDD and EF Core are independent concepts, they can work together effectively with careful separation of concerns.
Understanding the Roles:
- DDD focuses on the business domain and the logic that represents real-world concepts, such as entities, value objects, aggregates, and services. 🧠
- EF Core provides an easy way to map and persist data between objects in your application and database tables, but it is concerned only with data access, not business logic. 💾
It’s essential to decouple these two to maintain the integrity of the design. DDD entities should not inherit from EF Core's DbContext or Entity classes. Instead, they should represent the business logic without depending on persistence technologies.
Decoupling DDD and EF Core 🔀
To ensure the separation of concerns:
- Domain Entities: Should represent real-world business concepts and behaviors, independent of persistence concerns. 🏢
- EF Core Entities: Are purely data models that map to database tables. These should be kept separate from the DDD entities. 📊
- Mapping Tools: Use tools like AutoMapper or Mapster to map between domain models and persistence models, ensuring that domain logic remains independent of persistence technologies. 🛠️
This separation allows you to keep your domain logic clean and focused on business rules, while persistence concerns are handled independently.
The Role of Command and Query Handlers in CQRS 📝
CQRS (Command Query Responsibility Segregation) helps separate read and write operations, allowing different technologies to be used for each. In this architecture:
- Command Handlers handle write operations. ✍️
- Query Handlers manage read operations. 📖
Command Side (Write Operations) – EF Core:
When a command needs to modify the database (like creating or updating an entity), EF Core is often used due to its ability to easily handle CRUD operations. For example, creating a new employee might look like this:
public class RegisterEmployeeCommandHandler : IRequestHandler<RegisterEmployeeCommand, bool>
{
private readonly IOrgManagerDbContext _dbContext;
public RegisterEmployeeCommandHandler(IOrgManagerDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task<bool> Handle(RegisterEmployeeCommand request, CancellationToken cancellationToken)
{
var employee = new Employee
{
Name = request.Name,
Position = request.Position,
DateOfJoining = request.DateOfJoining
};
_dbContext.Employees.Add(employee);
await _dbContext.SaveChangesAsync(cancellationToken);
return true;
}
}
In this case, EF Core simplifies the write-side operations, handling entity persistence, and ensuring that business logic is maintained.
Query Side (Read Operations) – Dapper:
For read operations, especially complex queries, Dapper is more efficient. It’s a micro-ORM that allows you to execute raw SQL queries for high-performance, data-heavy operations. For example, fetching employee details using Dapper:
public class GetEmployeeDetailsQueryHandler : IRequestHandler<GetEmployeeDetailsQuery, EmployeeDetailsViewModel>
{
private readonly IDapperQueryFacade _queryFacade;
public GetEmployeeDetailsQueryHandler(IDapperQueryFacade queryFacade)
{
_queryFacade = queryFacade;
}
public async Task<EmployeeDetailsViewModel> Handle(GetEmployeeDetailsQuery request, CancellationToken cancellationToken)
{
var query = "SELECT Id, Name, Position, DateOfJoining FROM Employees WHERE Id = @EmployeeId";
var employee = await _queryFacade.QueryFirstOrDefaultAsync<Employee>(query, new { request.EmployeeId });
if (employee == null)
{
throw new EntityNotFoundException("Employee not found");
}
return new EmployeeDetailsViewModel
{
Id = employee.Id,
Name = employee.Name,
Position = employee.Position,
DateOfJoining = employee.DateOfJoining
};
}
}
Dapper excels in performance when executing complex queries, making it ideal for the query side of CQRS, where read operations are typically more frequent.
Avoiding the Repository Pattern in CQRS 🚫
In CQRS, the need for the Repository Pattern is often reduced or eliminated. This pattern is typically used to abstract data access in traditional architectures, but in CQRS:
- Command Handlers directly modify data through technologies like EF Core. 🔧
- Query Handlers execute read operations directly using tools like Dapper. 🔍
By directly implementing data access in handlers, you avoid the overhead of introducing repositories for each query and command. This approach provides flexibility, reduces complexity, and improves performance.
Managing Persistence with EF Core and Dapper 💡
Despite using EF Core and Dapper for different operations (EF Core for writes and Dapper for reads), both technologies can be abstracted behind interfaces to ensure that the application remains decoupled from the specific persistence technology.
Persistence Facade
You can create a Persistence Facade interface to abstract database access. This ensures that database interactions are decoupled from the application layers and can be easily tested.
Example of a query facade interface:
public interface IDapperQueryFacade
{
Task<T> QueryFirstOrDefaultAsync<T>(string sql, object parameters);
Task<IEnumerable<T>> QueryAsync<T>(string sql, object parameters);
}
By injecting this facade into query handlers, you decouple the application from the persistence technology, making it easier to swap out Dapper or EF Core if necessary.
Structuring Layers in Clean Architecture 🏗️
In Clean Architecture, the layers are structured as follows:
- Application Layer: Manages business logic, handles commands and queries, and orchestrates interactions between the Domain and Persistence layers. 📊
- Persistence Layer: Handles data access, including the implementation of EF Core DbContext and Dapper query facades. 💾
- Infrastructure Layer: Deals with low-level concerns such as external APIs, identity management, and file access. 🌐
- Presentation Layer: Exposes an API (e.g., RESTful or GraphQL) to communicate with clients, often interacting with the Application Layer. 📱
This separation ensures that each layer has a distinct responsibility, leading to a clean, maintainable, and scalable architecture.
Conclusion: Building Resilient Systems with DDD and CQRS 🌟
In conclusion, DDD and EF Core should be seen as complementary, each serving its distinct purpose. DDD focuses on the business logic and modeling real-world concepts, while EF Core (or Dapper) handles data persistence. By maintaining a clear separation between these concerns and using tools like AutoMapper for mapping between domain and persistence models, you ensure that your system remains clean and adaptable.
The integration of CQRS further strengthens this approach by segregating read and write operations. EF Core is ideal for handling write operations due to its CRUD capabilities, while Dapper excels in performance-sensitive read operations. By directly implementing database queries in handlers, you eliminate unnecessary abstractions, optimizing both flexibility and performance.
Ultimately, this architecture provides a foundation for building applications that are scalable, maintainable, and adaptable. Whether you're building a small startup application or a large-scale enterprise system, this approach allows your software to evolve without losing sight of its core principles.
By focusing on the continuous improvement of both architecture and processes, teams can develop systems that stand the test of time, growing in complexity without sacrificing stability or performance. 🌍
Stay Tuned for Part 5! 🚀
In the next part of this series, we’ll dive deeper into additional software architecture topics that will help you further enhance your projects. From microservices to event-driven architectures, we’ll explore more advanced concepts to ensure your system remains adaptable and scalable as it grows.
Thank you for reading! 🙏
Enjoyed this post? Don’t miss out on deeper insights and practical guides in the world of software engineering! Subscribe to my Substack for exclusive content, in-depth explorations, and valuable tips to enhance your journey as a developer. Let’s level up together! 🚀
Top comments (0)