Search code examples
c#domain-driven-designrepository-patterncqrsvalue-objects

In DDD(Domain Driven Design), can Repository methods accept Entities or Value Objects that the Aggregate is composed from?


As far as I know, each Aggregate root has it's own repository and the repository should work only with Aggregate Entity and it's Primary Key.

For example, for the Aggregate Appointment:

    public class Appointment
    {
        public int Id { get; set; }
        public DateOnly ForDate { get; set; }
        public string ForTimeInterval { get; set; }
        public Person ForPerson { get; set; }
    }

one version of Repository for Appointment could be the following:

    public class AppointmentRepository
    {
        public Appointment FindById(int Id) { }
        public void Appointment Add(Appointment appointment) { }
        public void Remove (Appointment appointment) { }
        public int Count() { }
    }

My question is, are any of the following methods allowed in the Repository or should they be in some other place, like AppointmentDataAccess(DAO/DAL):

    public class Repository
    {
        . . .

        public IEnumerable<Appointment> FindAppointmentsForDate(DateOnly date) { }

        public IEnumerable<Appointment> FindAppointmentsForPerson(Person person) { }
        . . . 
    }

As a side note, I need to make a query that returns all free TimeIntervals for some Date. TimeInterval is also free if there are no more than X amount of reservations. All of this is business logic so it makes sense, to me, to have the two methods in the Repository and check logic in Application Service(or Domain Service maybe???). The data is used for populating Drop-downs or similar UI elements, which usually I do using more abstract Data Access logic like DAO/DAL and the sorts.

Question is tagged CQRS but is not necessary to post an answer for it.


Solution

  • 1. Your first question of FindAppointmentsForDate

    Consider what would happen if you don't have that method in the repository. You would be forced to retrieve every entity into memory and filter them in the service. You might end up retrieving millions of entities every time. Something like this:

    NB: This is a bad idea!

    public class AppointmentService(AppointmentRepository repo)
    {
        public IEnumerable<Appointment> FindAppointmentsForDate(DateOnly date)
        {
            // Get all appointments for all time.
            var all = await repo.GetAllAppointments();
    
            // Filter in memory.
            return all.Where(a => a.ForDate.Date == date).ToArray();
        }
    }
    

    It's unlikely to be practical, and I imagine you'll instinctively (correctly!) feel that's a bad idea.

    So, it's entirely appropriate to do simple filtering like this in a repository. Common examples are:

    • Date ranges
    • Foreign key lookups, such as an EXISTS on another SQL table
    • Partial matches, such as where a string "starts with" something

    2. Your next question about finding free intervals

    This starts to look like business logic, but that doesn't mean a hard "no" to having a repository method. The answer depends on several factors:

    1. Is the logic simple enough that it would never require unit testing.
    2. Are there strong performance reasons why the repository must implement the logic.

    If you can confidently answer "yes" to both of the above, you might consider putting that logic in the repository implementation. Your first question meets these criteria. But for your second question: it's hard to give an answer without knowing more about the entities.

    To give some idea of why the exact use-case is important, here are two examples:

    If the logic is really simple

    Assumptions:

    1. Static rule of "no more than X appointments per timeslot".
    2. We're using SQL.
    3. There's a table for Appointments
    4. There's another table for Slots

    Maybe we could say there's not much that would need unit testing here. So we could implement it with a simple SQL statement with an outer join, a GROUP BY and a HAVING. The exercise left to the reader! Your repository method might end up like:

    IEnumerable<DateTimeOffset> FindFreeSlotsForDate(DateOnly date);
    

    If the logic is more complex

    Let's say, perhaps, the maximum number of appointments per timeslot varies depending on other factors. For example:

    • You can only have 1 type of "Special Appointment" in a given timeslot, but up to 4 types of "Normal Appointment"
    • There are no appointments on Public Holidays
    • Appointments aren't booked until they're confirmed.

    Now, you'd probably want to unit test the logic. You want to try different test cases and validate it. Your repository should just have a FindAppointmentsForDate and the business logic would be implemented in the service.

    This is a completely fictional example, but hopefully it illustrates the level of complexity that belongs in a service rather than a repository:

    public record Timeslot(
        String TimeslotType, // E.g. Weekday
        TimeOnly TimeOfDay,  // E.g. 1100
        int MaxNormalAppointmentsPermitted,
        int MaxSpecialAppointmentsPermitted
    );
    
    public class AppointmentService(AppointmentRepository repo)
    {
        public async Task<IEnumerable<Timeslot>> FindFreeTimeslots(DateOnly date)
        {
            var appointments = await repo.FindAppointmentsForDate(date);
            Timeslots[] allTimeslots = await repo.GetTimeslotsConfiguration();
            var appointmentsByTime = appointments.ToLookup(a => a.ForDate);
    
            var freeTimeslots = allTimeslots.Where(t =>
            {
                var appointmentsThisSlot = appointmentsByTime[t.TimeOfDay];
    
                var specialAppointments = appointmentsThisSlot.Where(a => a.Type == "Special");
                var normalAppointments = appointmentsThisSlot.Where(a => a.Type == "Normal");
    
                if (appointmentsThisSlot.Count() > specialAppointments.Count() + normalAppointments.Count())
                {
                    throw new MyException("Can't handle this appointment type");
                }
    
                if (normalAppointments.Count() > t.MaxNormaAppointmentsPermitted) return false;
                if (specialAppointments.Count() > t.MaxSpecialAppointmentsPermitted) return false;
    
                return true;
            }
    
            return freeTimeslots.ToArray();
        }
    }
    

    Hope that helps!