1
\$\begingroup\$

I am designing a solution to store employee day attendance, which can be recorded either as hours or as an interval within a day. The goal is to maintain a monthly attendance list for each employee. Depending on hours or interval values, timeSheet view will be generated containing either intervals or hours (each employee can have either hours or intervals provided to their attendance)

To implement this, I have created an Attendance domain class (which will have same structure in sql table) that represents a specific attendance record, which can be categorized as work, holiday, or sick leave. The class has the following properties:

public class Attendance : IAggregateRoot
{
        public Attendance(
            DateTime day,
            TimeInterval? interval, // value object containing timespan of startsAt and endAt
            double? hours,
            AttendanceType type)
        {
            day = day.Date;
            if (hours is null && interval is null) throw new Exception("At least one parameter must be provided.");
    
            Id = Guid.NewGuid();
            Type = type;
            if (interval != null)
            {
                if (interval.StartsAt.Hours >= 24 || interval.EndsAt.Hours >= 24) throw new BadRequestException(Errors.Attendances.IntervalOverflow);
    
                StartsA = day.AddHours(interval.StartsAt.Hours);
                EndsAt = day.AddHours(interval.EndsAt.Hours);
    
                Range = new DateRange(startDate, endDate);
                Hours = interval.TotalHours();
            }
            else
            {
                Range = new DateRange(day, day.AddHours(24));
                Hours = hours!.Value;
            }
   
        }

        public Guid Id { get; private set; }
        public DateTime Day => Range.StartsAt.Date;
        public DateTime StartsAt { get; protected set; }
        public DateTime EndsAt { get; protected set; }
        public double Hours { get; private set; }
        public AttendanceType Type { get; private set; }
        public Guid EmployeeId { get; private set; }
    }
}

If an interval is provided, I validate that the start and end times are within the valid range of 0-23 hours. Then, I set the StartsAt and EndsAt properties accordingly and calculate the total hours.

For cases where only hours are provided, I assume the attendance occurred within a single day, and I create a date range from the given day parameter to the next day, spanning 24 hours.

Pros:

  • It is quite simple to search for attendances on a specific day.
  • It is flexible and easy to maintain.

Cons:

  • It creates many records. For each employee, it will contain about 40 records each month. However, SQL can easily handle this.
  • It can be unclear when there is an interval. It has "startsAt" and "endsAt" specific fields, but when hours are inputted, it creates an interval for the full day, which can be unclear. Perhaps it would be better to store an additional field called "Date" that specifies which exact day the attendance belongs to.
  • There can be multiple attendances per day with hours which will contain same startsAt and endsAt fields and will make overlap that (but it's not a problem as only hours will be shown)

I would like to get feedback on whether this implementation is valid and if there are any improvements or suggestions to make it more robust. I am afraid that I making something wrong having hours or interval together in attendance.

\$\endgroup\$
4
  • \$\begingroup\$ What would happen when the employee works on the night shift and starts working at 10:00 pm and ends on the next day? EndsAt = day.AddHours(interval.EndsAt.Hours) -> does not cover that. I think you should consider using EndsAt = StartsAt.AddHours(interval.TotalHours()). \$\endgroup\$
    – Radu Hatos
    Commented Jun 20, 2023 at 12:55
  • 1
    \$\begingroup\$ If employee works two days basically two attendances will be generated. First one attendance will be from 10:00pm to next day 00:00am and the other attendance from 00:00am to 5:00am (let's say) \$\endgroup\$
    – Andrius
    Commented Jun 20, 2023 at 13:05
  • \$\begingroup\$ Is there any reason why do you DateTime instead of DateTimeOffset? Which .NET and C# versions are you using? \$\endgroup\$ Commented Jun 20, 2023 at 15:19
  • \$\begingroup\$ because all users are in the same country I don't need to save local time and instead of that DateTime.UtcNow is used \$\endgroup\$
    – Andrius
    Commented Jun 23, 2023 at 9:56

1 Answer 1

6
\$\begingroup\$

I would change my parameters to simply require "login" and "logout" timestamps.

Then calculate the difference between those two DateTimes. I would move all validation into DI behaviour, so it's easy to test independently, and change, based on PO or customer feedback.

That should clean up the messy constructor somewhat.

I allowed for throwing an exception on an invalid datetime to and from provided, because that is what your original code did, I would however NOT recommend this approach, it is quicker, and cleaner, if you simply handle the invalid data, and follow a nullObject pattern or something similar, so that invalid parameters result in logging, and no action, rather than halting the code. Halting a program is rarely the desired effect.

The code would look something like this:

public class AttendanceFromTo : IAggregateRoot
    {
        public AttendanceFromTo (
            Guid employeeId,
            DateTime from,
            DateTime to,
            AttendanceType type, IShiftValidator shiftValidator)
        {
            shiftValidator.ValidateFromAndTo(from, to);
            Id = Guid.NewGuid();
            EmployeeId = employeeId;
            Type = type;
            TimeSpan spanFromTo = (to - from);
            Hours = spanFromTo.TotalHours;
            StartsAt = from;
            EndsAt = to;
        }
    
        public Guid Id { get; private set; }
        public DateTime StartsAt { get; protected set; }
        public DateTime EndsAt { get; protected set; }
        public double Hours { get; private set; }
        public AttendanceType Type { get; private set; }
        public Guid EmployeeId { get; private set; }
    }
    
    
    public class ShiftValidator : IShiftValidator
    {
        public void ValidateFromAndTo(DateTime from, DateTime to)
        {
            if (to - from < TimeSpan.Zero)
            {
                throw new ArgumentException("From cannot be greater than to");
            }
    
            if (to - from > TimeSpan.FromHours(24))
            {
                throw new ArgumentException("Shift cannot be longer than 24 hours");
            }
        }
    }
    
    public interface IShiftValidator
    {
        void ValidateFromAndTo(DateTime from, DateTime to);
    }

If you need to supply "only" the hours ->

public class AttendanceHours : IAggregateRoot
    {
        public AttendanceHours (
            Guid employeeId,
            decimal hours,
            AttendanceType type)
        {
            Hourse = hours;
            Id = Guid.NewGuid();
            EmployeeId = employeeId;
            Type = type;
        }

        public Guid Id { get; private set; }
        public decimal Hours { get; private set; }
        public AttendanceType Type { get; private set; }
        public Guid EmployeeId { get; private set; }
    }

if you "need" a start date or something similar: DI that functionality in, just like the validation on the other class.

This will make adjusting whatever logic you have that needs to exist based on an hours input, to be tested independently of this class.

\$\endgroup\$
1

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.