How to inject service to domain entity and how to persist entity

Viewed 703

I'm creating a Tour planner. The Tour always starts and ends at the same coordinates. Between the start and end, there are Stops. Stops contain a specific Sight to visit during the tour + the time of visit. When adding a Sight to the tour, I insert a new Stop, and recalculate my Stops' arrival time, based on their distances from one another. For this I use the injected ITravelService instance, in the private recalculateStopTimes(int fromIdx) method.


My problem is the following: this works until I want to persist a Tour object in the database through an ORM. The functionality will be lost after retrieval, because of the private ITravelService. I thought about injecting the serivce through the InsertSight/RemoveSight/RemoveStop methods, but then I would need to inject it with every public method I create, that modifies Stops. Is there a better way to inject a service like this, to an entity? Or should I even inject it? If not, how could I get the same functionality (tour recalculating it's stops)?

public interface ITravelService
{
        public TimeSpan CalculateTimeBetween(Coordinates from, Coordinates to);
}

public class Tour : ITour
    {
        private readonly List<Stop> _stops;
        private ITravelService _travelService;

        public IReadOnlyList<Stop> Stops { get { return _stops; } }
        public bool IsWithinLimit { get { return _stops.Last().TimeRange.From < (StartTime.TimeOfDay + Length); } }


        public Tour(DateTime startTime, TimeSpan length, Coordinates start, ITravelService travelService)
        {
            StartTime = startTime;
            Length = length;
            Stop firstStop = new Stop(start, new TimeRange(startTime.TimeOfDay, startTime.TimeOfDay));
            Stop lastStop = new Stop(start, new TimeRange(startTime.TimeOfDay, startTime.TimeOfDay));
            _stops = new List<Stop>() { firstStop, lastStop };
        }

        private void recalculateStopTimes(int fromIdx)
        {
            for (int i = fromIdx; i < _stops.Count - 1; i++)
            {
                Stop currentStop = _stops[i];
                Stop nextStop = _stops[i + 1];
                var travelTime = _travelService.CalculateTimeBetween(currentStop.Coordinates, nextStop.Coordinates);
                nextStop.Arrival = currentStop.TimeRange.To + travelTime;
            }
        }

        public void InsertSight(Sight sight, int index)
        {
            if (index == 0 || index == Stops.Count) throw new ArgumentOutOfRangeException("Cannot insert before first, or after last stop.");
            _stops.Insert(index, new SightStop(sight, StartTime.DayOfWeek));

            recalculateStopTimes(index - 1);
        }

        public void RemoveSight(Sight sightToRemove)
        {
            if (_stops.Count == 2) throw new ArgumentException("Sight is not in tour");
            int idx = 1;
            while (((_stops[idx] as SightStop).Sight != sightToRemove) && idx <= _stops.Count - 1)
            {
                idx++;
            }
            if (idx < _stops.Count)
            {
                RemoveStopAt(idx);
            }
            else
            {
                throw new ArgumentException("Sight is not in tour");
            }
        }

        public void RemoveStopAt(int index)
        {
            if (index > 0 && index < _stops.Count - 1)
            {
                _stops.RemoveAt(index);
                recalculateStopTimes(index - 1);
            }
            else
            {
                throw new ArgumentOutOfRangeException("Index was out of range");
            }
        }

        public IReadOnlyList<Sight> SightsInTour
        {
            get
            {
                return _stops.Where(stop => stop is SightStop).Select(x => (x as SightStop).Sight).ToList();
            }
        }
    }
2 Answers

If you want to stick with the DDD approach where business logic resides inside the Domain Entity, the answer is to apply Method Injection:

// ITravelService is injected into the public InsertSight method
public void InsertSight(Sight sight, int index, ITravelService travelService)
{
    ...
}

Method Injection injection is ideal, because constructing objects with both runtime data and dependencies (using Constructor Injection) causes all kinds of trouble. With Method Injection, instead, the consuming class, Tour, only uses the dependency, but never stores the dependency in any field.

Tips:

  • If you need the dependency in other private methods, pass the dependency from method to method
  • If your Domain method requires many dependencies, you'll experience a problem similar to the problem of constructor over-injection. Constructor over-injection is caused by a class that does too much. With method over-injection, that method does too much. There are many refactorings and design techniques that can be applied to solve this problem, such as the Facade Service refactoring, or designs such as Domain Events, which is a really powerful part of Domain-Driven Design.

Entities represent data state and should only represent data state. Let your recalculation service receive entities, inspect, and modify them if need be. Beyond validating data-level concerns (required vs. optional, string length, etc.) business logic should be kept out of the entity.

One issue you might encounter is that you are relying on the order of the stops in your tour. This will most likely not be reliable counting on the order of the collection in an entity unless you explicitly introduce something like a StopNumber when inserting and removing stops, and use an OrderBy clause when working with the child collection. The act of adding or removing a stop involves inserting/removing an element and updating travel times. This can be moved into the service as well.

public interface ITravelService
{
    public void UpdateTravelTimes(Tour tour);
    public void AddSightStopToTour(Tour tour, Sight sight);
    public void RemoveSightStopFromTour(Tour tour, Sight sight);
}

I would even recommend avoiding passing entities into the method and instead pass the IDs of the entities. The service can hold dependencies to the Repositories or DbContext and this ensures that entities are loaded with their necessary collections eager loaded rather than possibly receiving a Tour without it's Stops and then tripping a lazy load.

public interface ITravelService
{
    public Tour UpdateTravelTimes(int tourId);
    public Tour AddSightStopToTour(int tourId, int sightId);
    public Tour RemoveSightStopFromTour(int tourId, int sightId);
}

public class TravelService : ITravelSerivce
{
    public Tour UpdateTravelTimes(int tourId, int fromIndex)
    {
        if(fromIndex < 0) 
            throw new IndexOutOfRangeException("fromIndex < 0");

        var tour = Context.Tours
            .Include(x => x.Stops)
            .Single(x => x.TourId == tourId);
           
        if(fromIndex >= tour.Stops.Count())
            return tour; // Nothing to do.

        var orderedStops = tour.Stops
            .OrderBy(x => x.StopNumber)
            .ToList();

        for (int i = fromIndex; i < orderedStops.Count - 1; i++)
        {
            Stop currentStop = orderedStops[i];
            Stop nextStop = orderedStops[i + 1];
            var travelTime = calculateTimeBetween(currentStop.Coordinates, nextStop.Coordinates);
            nextStop.Arrival = currentStop.TimeRange.To + travelTime;
        }

        return tour;
    }
}

The above example assumes either the DbContext is injected as a dependency or can be resolved from a unit of work.

Adding and removing stops would load the tour and the stops collection, insert / remove the stop respectively then adjust the StopNumber of the remaining stops after the insert/remove and recalculate the arrival times. The tour can then be returned with it's stops ready to be consumed to populate view models or such to send back to the view.

Related