[Table("slots")]
public class Slot
{
public int Id { get; set; }
public int DoctorId { get; set; }
public DateTime StartsAt { get; set; }
public bool IsBooked { get; set; }
}
[Table("appointments")]
public class Appointment
{
public int Id { get; set; }
public int SlotId { get; set; }
public int PatientId { get; set; }
public string Status { get; set; } = "";
public DateTime CreatedAt { get; set; }
}public class BookAppointmentUseCase
{
private readonly ClinicDbContext _db;
private readonly SmsClient _sms;
public BookAppointmentUseCase(ClinicDbContext db, SmsClient sms)
{
_db = db;
_sms = sms;
}
public async Task<int> Execute(BookAppointmentRequest request)
{
var slot = await _db.Slots.FirstOrDefaultAsync(s => s.Id == request.SlotId);
if (slot == null)
throw new InvalidOperationException("Слот не найден");
if (slot.StartsAt <= DateTime.UtcNow)
throw new InvalidOperationException("Нельзя записаться на прошедшее время");
// «Один слот — одна запись»: флаг в slots плюс запрос в appointments
var alreadyBooked = slot.IsBooked || await _db.Appointments
.AnyAsync(a => a.SlotId == slot.Id && a.Status != "cancelled");
if (alreadyBooked)
throw new InvalidOperationException("Слот уже занят");
var patient = await _db.Patients.FirstOrDefaultAsync(p => p.Id == request.PatientId);
if (patient == null)
throw new InvalidOperationException("Пациент не найден");
var appointment = new Appointment
{
SlotId = slot.Id,
PatientId = patient.Id,
Status = "booked",
CreatedAt = DateTime.UtcNow
};
_db.Appointments.Add(appointment);
slot.IsBooked = true;
await _db.SaveChangesAsync();
await _sms.SendAsync(patient.Phone, $"Вы записаны на {slot.StartsAt:dd.MM HH:mm}");
return appointment.Id;
}
}public sealed class Slot
{
public Guid Id { get; }
public DateTime StartsAt { get; }
public Guid? BookedBy { get; private set; }
public Slot(Guid id, DateTime startsAt, Guid? bookedBy = null)
{
Id = id;
StartsAt = startsAt;
BookedBy = bookedBy;
}
public void Book(Guid patientId, DateTime now)
{
if (StartsAt <= now)
throw new DomainException("Нельзя записаться на прошедшее время");
if (BookedBy is not null)
throw new DomainException("На один слот — одна запись");
BookedBy = patientId;
}
public void Cancel() => BookedBy = null;
}
public sealed class DomainException : Exception
{
public DomainException(string message) : base(message) { }
}public interface ISlotRepository
{
Task<Slot?> FindAsync(Guid slotId);
Task SaveAsync(Slot slot);
}
public interface IPatientNotifier
{
Task NotifyBookedAsync(Guid patientId, DateTime startsAt);
}
public sealed record BookAppointment(Guid SlotId, Guid PatientId);
public sealed class BookAppointmentUseCase
{
private readonly ISlotRepository _slots;
private readonly IPatientNotifier _notifier;
private readonly TimeProvider _clock;
public BookAppointmentUseCase(ISlotRepository slots, IPatientNotifier notifier, TimeProvider clock)
{
_slots = slots;
_notifier = notifier;
_clock = clock;
}
public async Task Execute(BookAppointment command)
{
var slot = await _slots.FindAsync(command.SlotId)
?? throw new InvalidOperationException("Слот не найден");
slot.Book(command.PatientId, _clock.GetUtcNow().UtcDateTime);
await _slots.SaveAsync(slot);
await _notifier.NotifyBookedAsync(command.PatientId, slot.StartsAt);
}
}[Table("slots")]
public class SlotRow
{
public Guid Id { get; set; }
public DateTime StartsAt { get; set; }
public Guid? BookedBy { get; set; }
}
public sealed class EfSlotRepository : ISlotRepository
{
private readonly ClinicDbContext _db;
public EfSlotRepository(ClinicDbContext db) => _db = db;
public async Task<Slot?> FindAsync(Guid slotId)
{
var row = await _db.Slots.FindAsync(slotId);
return row is null ? null : new Slot(row.Id, row.StartsAt, row.BookedBy);
}
public async Task SaveAsync(Slot slot)
{
var row = await _db.Slots.FindAsync(slot.Id)
?? throw new InvalidOperationException("Слот не найден");
row.BookedBy = slot.BookedBy;
await _db.SaveChangesAsync();
}
}| Критерий | Layered Architecture | Onion Architecture |
|---|---|---|
| Что в центре | База данных | Доменная модель |
| Куда смотрят зависимости | Сверху вниз, к данным | Снаружи внутрь, к домену |
| Где бизнес-правила | В use case рядом с запросами | В доменных сущностях и сервисах |
| Тестируемость | Нужна база или моки ORM | Домен тестируется без инфраструктуры |
| Цена замены инфраструктуры | Правки во всех слоях | Новая реализация порта |